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:
| Mechanism | What it does | Controlled by |
|---|---|---|
| Direction flip | Narrow viewports prefer vertical flow; wide ones prefer horizontal | flipDirectionBelow |
| Spacing compression | Scales spacing and padding toward the viewport target | compressSpacing, minSpacingScale, maxSpacingScale |
| Scale-to-fit | Uniform transform: scale when the scene still overflows width | scaleToFit |
All three are enabled by default. Disabling them is how you lock a diagram at its natural size.
Direction flip
The author writes flowchart LR. Below flipDirectionBelow (default 640), the layout reorients to vertical so content
gets height instead of fighting for width. At or above the threshold, horizontal is preferred.
| Author direction | Below threshold | At / above threshold |
|---|---|---|
LR | stays LR (already horizontal) — actually flips to TB | stays LR |
RL | flips to BT | stays RL |
TB / TD | stays TB | flips to LR |
BT | stays BT | flips to RL |
The flip is a layout decision, not a CSS transform. The SVG dimensions and edge routes are recomputed for the new direction.
MermoidAscent.svgDiagram(chain, RenderConfig(responsive = ResponsiveConfig(flipDirectionBelow = None)))That is the unconstrained layout: five nodes in a horizontal chain. Now with viewport-driven direction flip at 640px:
{
val wide = sceneOf(chain, Some(Viewport(900)))
val narrow = sceneOf(chain, Some(Viewport(400)))
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")
}Wide (864×165) direction: LR
Narrow (164×619) direction: TB
Flipped: trueSpacing 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.
{
val big = sceneOf(chain, Some(Viewport(1200)))
val small = sceneOf(chain, Some(Viewport(320)))
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")
}Wide width: 1014
Narrow width: 149
Compression ratio: 0.14Disable compression to keep the author's geometry intact regardless of viewport:
{
val compressed = sceneOf(chain, Some(Viewport(320))) // default compressSpacing = true
val noCompress = MermaidParser
.parse(chain)
.map { d =>
DiagramLayout.scene(
d,
RenderConfig(responsive = ResponsiveConfig(compressSpacing = false)),
Some(Viewport(320)),
)
}
.getOrElse(throw new AssertionError("unparseable"))
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: 149px wide
Without compression: 170px wide
Rigid is wider: trueScale-to-fit
After layout, if scene.width > viewport.maxWidth and scaleToFit is enabled (default), the painters may apply a
uniform transform: scale(scene.fitScale(maxWidth)). This keeps HTML nodes and SVG edges aligned in hybrid mode —
both scale together instead of one overflowing.
Spacing compression handles moderate overflows, but a dense hub still exceeds its budget even at minimum spacing. That is when uniform scaling kicks in:
{
val hub =
"""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 = sceneOf(hub, 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: 701
Viewport: 320
fitScale(320): 0.45614035087719296
Needs scaling: trueDisabling responsive entirely
Three knobs to turn off. Omitting the Viewport altogether is the simplest approach — layout runs unconstrained.
{
val constrained = sceneOf(chain, Some(Viewport(320)))
val unconstrained = sceneOf(chain, 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: 149×545
Unconstrained: 814×155
Unconstrained is wider: trueIn hybrid mode
mermoid-ascent recomputes the entire DiagramScene on every width change — geometry, edge routes, and direction.
Selection state is preserved by node id, so clicking a node before reflow keeps it selected after.
See Interactive for live Narrow / Medium / Wide controls. The built-in buttons set 360px, 640px, and 900px; the threshold between vertical and horizontal flow sits at 640px by default.
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 showWidthControls = false.