Custom CSS

RenderConfig.customStylesheet is merged over the chosen theme. This is the whole styling story: mermoid has no theme object to subclass and no per-shape configuration knobs — you write CSS, and it wins.

From a CSS string

CssParser.parse returns Either[CssParseError, Stylesheet]; CssParseError.message says where it stopped and what it expected. It handles :root variable blocks, class/id/element/compound/ descendant selectors, pseudo-classes, hex colours, lengths, numbers, quoted strings, var() with fallbacks, composite values, and /* comments */.

CssParser.parse(overrides) match
  case Right(sheet) =>
    MermoidAscent.svgDiagram(pipeline, RenderConfig(customStylesheet = Some(sheet), resolveVariables = false))
  case Left(error) => _root_.ascent.ast.UI.Text(error.message)
ReadTransformWrite

That is the same diagram as the Default theme renders — only the stylesheet changed. Note resolveVariables = false here: the overridden variables stay as var() references so anything further up the cascade can override them again.

Merge semantics

Stylesheet.merge(base, overrides):

  • variables — map union, overrides winning per key

  • rules — base.rules ++ overrides.rules, in that order

Rules append rather than replace, so a custom rule with the same selector as a built-in one relies on ordinary CSS source order to win. That is deliberate: it means you can override one declaration without restating the rest of the rule.

The second diagram below only adds stroke-width: 4 on .node-shape. The rest of the Default theme is unchanged.

MermoidAscent.svgDiagram(pipeline)
ReadTransformWrite
CssParser.parse(".node-shape { stroke-width: 4; }") match
  case Right(mine) => MermoidAscent.svgDiagram(pipeline, RenderConfig(customStylesheet = Some(mine)))
  case Left(error) => _root_.ascent.ast.UI.Text(error.message)
ReadTransformWrite

Building the AST directly

For CSS generated in Scala, skip the parser and build Stylesheet values. The AST is small: CssValue, CssSelector, CssDeclaration, CssRule, Stylesheet — all plain case classes and enums, so a stylesheet can be computed, folded over, or derived from application data.

{
      import _root_.mermoid.css.*
      // A per-status palette computed in Scala rather than written as CSS text — one rule per entry,
      // matching the `classDef`-assigned class names in the diagram source.
      val statusColors = List("ok" -> "#16a34a", "warn" -> "#ca8a04", "fail" -> "#dc2626")
      val rules        = statusColors.map { (name, color) =>
        CssRule(
          CssSelector.Descendant(CssSelector.Class(name), PaintClass.NodeShape.selector),
          List(CssDeclaration("stroke", CssValue.Color(color)), CssDeclaration("stroke-width", CssValue.Number(3))),
        )
      }
      MermoidAscent.svgDiagram(
        Mermaid("""flowchart LR
    A[Healthy] --> B[Degraded]
    B --> C[Down]
    class A ok
    class B warn
    class C fail
""".stripMargin),
        RenderConfig(customStylesheet = Some(Stylesheet(rules = rules))),
      )
    }
HealthyDegradedDown

classDef, class and style

The in-diagram styling statements interact with a custom stylesheet like this. Later rows win.

LayerWhere it landsWhat it loses to
Hybrid chrome layout.mermoid-node and .mermoid-node .node-shape (position, inset, z-index)nothing written as paint. These keep the shape inside the button
Hybrid chrome paint:where(.mermoid-node) for color, :where(.mermoid-node) .node-shape for background and borderthe theme, classDef, and style. :where adds no specificity
Theme and customStylesheetthe embedded stylesheet, before classDefclassDef and style
classDef n p:vSVG: .n and .n .node-shape. Hybrid also copies those onto .mermoid-node.n and .mermoid-node.n .node-shape, which outrank .mermoid-node .node-shapestyle
class A n / A:::nthe node's class attribute. It selects. It does not paint
style A p:vinline style. fill and stroke land on the shape (background, border-color). color lands on the node, because the label is a sibling of the shapean !important rule in the host page

These statements work on flowcharts and stateDiagram-v2. fill becomes background on the inner .node-shape, and stroke becomes border-color, in both SVG and hybrid HTML. Hosts can keep writing SVG paint properties.

Two hybrid shapes do not take classDef paint. A rhombus draws its visible fill on .mermoid-node-diamond-fill, and the [*] marker keeps .mermoid-node.start-end .node-shape. style on a rhombus still sets inline background on .node-shape, which is the diamond's stroke plate, not the inner fill.

MermoidAscent.diagram(
      Mermaid("""flowchart LR
  classDef warn fill:#4a4030,stroke:#e0c070
  A[Tired] --> B[Zipx]
  class A warn
""".stripMargin)
    )

Styling a diagram already on the page

Nothing above requires a re-render. Because every element carries a stable class and id (SVG structure), a stylesheet in the host page reaches into the diagram:

/* dim everything except the critical path */
#chart .node { opacity: 0.4; }
#chart .node.critical { opacity: 1; }
#chart #edge-happy .edge-line { stroke: #16a34a; stroke-width: 4; }

/* respond to the reader's preference — no second render */
@media (prefers-color-scheme: dark) {
  #chart { --mermoid-main-bkg: #1f2020; --mermoid-text: #e0e0e0; }
}

The @media rule only bites when the diagram was rendered with resolveVariables = false. That is the trade-off from Theming: resolved output is portable, var() output is themeable.