Responsive layout

Passing a Viewport(maxWidth) to the renderer tells it how much horizontal space is available. Three things may happen, all controlled by ResponsiveConfig:

MechanismWhat it doesControlled by
Direction flipOpt-in. Below the threshold prefers vertical; at or above prefers horizontalflipDirectionBelow (default None)
Spacing compressionScales spacing and padding toward the viewport targetcompressSpacing, minSpacingScale, maxSpacingScale
Container fitHybrid paint scales nodes and edges down to the column, never below a floorfit (ContainerFit.ToWidth(0.5) or Off)

Spacing compression and container fit are on by default. Direction is not. A viewport width changes spacing. It does not turn an authored TD into LR. A sequence diagram does not flip either: a narrow viewport compresses SequenceConfig.columnGap and rowPitch, then container fit scales the picture.

Direction flip

The default keeps the direction the author wrote. flipDirectionBelow = Some(640) opts in: below 640 the layout prefers vertical flow, and at or above 640 it prefers horizontal.

Author directionBelow thresholdAt / above threshold
LRflips to TBstays LR
RLflips to BTstays RL
TB / TDstays verticalflips to LR
BTstays BTflips to RL

The flip is a layout decision, not a CSS transform. The SVG dimensions and edge routes are recomputed for the new direction. A static embed does not know the reader's window, so it does not flip. Pass a Viewport together with flipDirectionBelow, or use diagramResponsive with a live width.

MermoidAscent.svgDiagram(chain)
OneTwoThreeFourFive

That is the authored layout: five nodes in a horizontal chain. The same chain with the flip opted in at 640px:

{
  val flip = RenderConfig(responsive = ResponsiveConfig(flipDirectionBelow = Some(640)))
  (for
    wide   <- sceneOf(chain, Some(Viewport(900)), flip)
    narrow <- sceneOf(chain, Some(Viewport(400)), flip)
  yield List(
    s"Wide (${wide.width.toInt}×${wide.height.toInt}) direction: ${wide.direction}",
    s"Narrow (${narrow.width.toInt}×${narrow.height.toInt}) direction: ${narrow.direction}",
    s"Flipped: ${wide.direction != narrow.direction}",
  ).mkString("\n")).getOrElse("not a ranked diagram")
}
Wide (864×165) direction: LR
Narrow (164×619) direction: TB
Flipped: true

Without that opt-in, a flowchart TD laid out for a 900px column stays top to bottom:

{
      val src =
        Mermaid("""flowchart TD
  A --> B
  B --> C
""".stripMargin)
      sceneOf(src, Some(Viewport(900))).fold("not a ranked diagram")(scene => s"Direction: ${scene.direction}")
    }
Direction: TD

Spacing compression

When compressSpacing is true (default), the layout estimates how many nodes sit along the main axis and scales spacing, padding, and parallel edge offset toward the viewport target. The scale is clamped between minSpacingScale (default 0.45) and maxSpacingScale (default 1.75).

A diagram that needs 960px of width in a 320px viewport compresses aggressively but never below 45% of default spacing.

(for
  big   <- sceneOf(chain, Some(Viewport(1200)))
  small <- sceneOf(chain, Some(Viewport(320)))
yield List(
  s"Wide width: ${big.width.toInt}",
  s"Narrow width: ${small.width.toInt}",
  s"Compression ratio: ${(small.width / big.width).toString.take(4)}",
).mkString("\n")).getOrElse("not a ranked diagram")
Wide width: 1014
Narrow width: 594
Compression ratio: 0.58

Disable compression to keep the author's geometry intact regardless of viewport:

{
  val compressed =
    DiagramLayout.scene(chain.diagram, RenderConfig(), Some(Viewport(320))) // compressSpacing = true
  val noCompress = DiagramLayout.scene(
    chain.diagram,
    RenderConfig(responsive = ResponsiveConfig(compressSpacing = false)),
    Some(Viewport(320)),
  )
  List(
    s"With compression:  ${compressed.width.toInt}px wide",
    s"Without compression: ${noCompress.width.toInt}px wide",
    s"Rigid is wider: ${noCompress.width > compressed.width}",
  ).mkString("\n")
}
With compression:  594px wide
Without compression: 814px wide
Rigid is wider: true

Container fit

After layout, hybrid paint scales the scaler that holds the HTML nodes and the SVG edges together. The scale is min(1, column / scene), and it never drops below the floor on ContainerFit.ToWidth (default 0.5). A scene that is already narrower than the column stays at scale 1. Below the floor the root scrolls horizontally.

Scene.fitScale is the same policy for a known width (the interactive reflow path). ContainerFit.Off leaves the scene at scale 1.

Spacing compression handles moderate overflows. A dense hub still exceeds its budget at minimum spacing, and that is when the floor matters:

{
      val hub =
        Mermaid("""flowchart LR
  A --> B
  A --> C
  A --> D
  A --> E
  A --> F
  A --> G
  B --> H
  C --> H
  D --> H
  E --> H
  F --> H
  G --> H
""".stripMargin)
      val scene = DiagramLayout.scene(hub.diagram, RenderConfig(), Some(Viewport(320)))
      List(
        s"Scene width: ${scene.width.toInt}",
        s"Viewport: 320",
        s"fitScale(320): ${scene.fitScale(320)}",
        s"Needs scaling: ${scene.fitScale(320) < 1.0}",
      ).mkString("\n")
    }
Scene width: 348
Viewport: 320
fitScale(320): 0.9195402298850575
Needs scaling: true

Disabling responsive entirely

Three knobs to turn off. Omitting the Viewport altogether is the simplest approach — layout runs unconstrained.

{
  val constrained   = DiagramLayout.scene(chain.diagram, RenderConfig(), Some(Viewport(320)))
  val unconstrained = DiagramLayout.scene(chain.diagram, RenderConfig(), None)
  List(
    s"Constrained: ${constrained.width.toInt}×${constrained.height.toInt}",
    s"Unconstrained: ${unconstrained.width.toInt}×${unconstrained.height.toInt}",
    s"Unconstrained is wider: ${unconstrained.width > constrained.width}",
  ).mkString("\n")
}
Constrained: 594×111
Unconstrained: 814×155
Unconstrained is wider: true

Measuring text

Node and label widths use TextMeasure. The default, TextMeasure.estimate, counts characters (LayoutConfig.charWidthEstimate) and ignores the font. The CLI, tests, SSR, and svg / svgDiagram use that estimate, so those SVGs stay the same on the JVM and on Scala.js.

An ascent diagram (diagram, diagramInteractive, and the responsive variants) that runs where a browser document exists, and whose caller left textMeasure empty, lays out from DomTextMeasure instead. A measure the caller passed is kept. Static pages on this site use svgDiagram, so they stay on the estimate. The interactive examples are remounted in the browser, and that mount is the one that measures.

RenderConfig(textMeasure = Some(DomTextMeasure()))

DomTextMeasure lives in mermoid-ascent and only compiles for Scala.js. It asks the canvas for measureText. Core does not take a DOM dependency, and it does not call java.awt. When document is missing, the estimate stays in place. The canvas helper's own fallback (half the font size per character) is only used when a host passes DomTextMeasure and the document is gone.

In hybrid mode

mermoid-ascent recomputes the entire Scene on every width change: geometry, edge routes, and, when flipDirectionBelow is set, direction. Selection state is preserved by node id, so clicking a node before reflow keeps it selected after.

diagramFitting follows the container: the first paint is unconstrained, then a resize observer re-lays the scene out at the container's content width. diagramResponsive is the same re-layout when the host already owns the width source. See Interactive for live Narrow / Medium / Wide controls. Those buttons set 360px, 640px, and 900px. Direction changes at 640px only when the diagram opts into flipDirectionBelow.

MermoidAscent.diagram(chain, RenderConfig(), Some(Viewport(640)))

For external width sources (e.g., a ResizeObserver on the container), use MermoidAscent.diagramResponsive with a Source[Double]. The built-in Narrow/Medium/Wide buttons are hidden when widthControls = WidthControls.Hidden.