Layout.
Three primitives cover almost every layout you will ever build:
Column stacks things top‑to‑bottom,
Row lays them out left‑to‑right, and
Grid arranges equal columns and card walls.
Reach for Center, Container, Box,
Spacer, and GridItem spans for the rest. No raw
CSS, no flexbox bookkeeping — just composition.
The mental model
Every layout in Aktion is a tree of containers. Pick the container by the question you are answering:
| You want… | Use |
|---|---|
| Things stacked vertically (a page, a card body, a form) | Column([…]) |
| Things side by side (a toolbar, a row of buttons, label + value) | Row([…]) |
| Equal columns / a reflowing card or KPI grid | Grid([…], { columns }) |
| Content centered on both axes (spinner, empty state, hero CTA) | Center([…], { minHeight }) |
| A 12‑column dashboard with mixed widths | Grid + GridItem(child, { span }) |
| A centered, max‑width reading column | Container([…], { size }) |
| Direction that flips on mobile vs desktop | Stack([…], { direction: {base, md} }) |
| A tinted, full‑bleed band inside a card | CardSection([…], { tone }) |
| Buttons that should read as one control | ButtonGroup([…]) |
| A long list or log clipped to a scrolling pane | ScrollArea([…], { maxHeight }) |
| A menu or popover that must escape a clipping parent | nothing — it is automatic |
gap and padding use the spacing scale
none · 3xs · 2xs · xs · sm · md · lg · xl · 2xl · 3xl
(none = 0).
Wherever you see a value below, it can also be a
responsive map like { base: "sm", md: "lg" }.
Column — stack vertically
Column is the workhorse: it is the usual page body and the
usual card body. Children stretch to the full width; control spacing with
gap and horizontal placement with align.
gap defaults to md and align to
stretch. Turn on wrap when the children should flow onto more
than one line — that also unlocks alignContent
(start/center/end/between/around/stretch),
which distributes the wrapped lines rather than the children.
Without wrap there is only one line, so alignContent does
nothing at all.
$app(Column([
PageHeader("Project Atlas", { subtitle: "Updated 2 hours ago" }),
Card([CardHeader("Overview"), Text("A Column stacks its children top to bottom with an even gap.")]),
Card([CardHeader("Activity"), Text("Each card is a child; the Column owns the spacing between them.")])
], { gap: "lg" }))
Row — lay out horizontally
Row places children left‑to‑right at their
natural width, vertically centered — exactly what
you want for toolbars, button groups, and label/value pairs. Push items to
opposite edges with a Spacer() or
justify: "between".
$app(Card([Row([
Text("Team members", { variant: "large-heavy" }),
Spacer(),
Button("Invite", { variant: "ghost", icon: "user-plus" }),
Button("New", { variant: "primary", icon: "plus" })
], { gap: "sm" })]))
Need equal‑width children (e.g. two cards sharing a row)? Pass
grow: true — or, better for true grids, use
Grid({ columns }). Need one child to expand while the rest hug
their content (a search box beside a button)? Wrap it in
StackItem(child, { grow: 1 }).
A Row keeps its children as separate controls with a gap between
them. When the buttons are meant to read as one control — a view
switcher, a paired action — reach for
ButtonGroup instead.
$app(Row([
StackItem(Input("q", { placeholder: "Search the docs…" }), { grow: 1 }),
Button("Search", { variant: "primary" })
], { gap: "sm" }))
Distributing children with justify
justify controls spacing along the main axis:
start (default), center, end,
between, around, evenly.
align controls the cross axis
(start/center/end/stretch).
$app(Column([
Card([Row([Badge("A"), Badge("B"), Badge("C")], { justify: "between" })]),
Card([Row([Badge("A"), Badge("B"), Badge("C")], { justify: "center" })]),
Card([Row([Badge("A"), Badge("B"), Badge("C")], { justify: "end" })])
], { gap: "md" }))
Grid — equal columns & card walls
Grid has three modes. Pick by intent:
| Mode | How | Best for |
|---|---|---|
| Auto‑fit (default) | omit columns | Card / KPI walls that reflow on their own — wraps as many ≥ minChildWidth columns as fit. |
| Fixed | columns: N (1–12) | Exactly N equal columns. |
| Span | GridItem children | 12‑track dashboards & sidebars with mixed widths. |
Auto‑fit — reflows by itself
$app(Grid([
StatCard("Revenue", { value: "$48.2k", trend: "up", delta: "+12%" }),
StatCard("Users", { value: "2,184", trend: "up", delta: "+184" }),
StatCard("Churn", { value: "1.4%", trend: "down", delta: "-0.3%" }),
StatCard("NPS", { value: "62", trend: "up", delta: "+5" })
], { gap: "md", minChildWidth: "180px" }))
Fixed columns
$app(Grid([
Card([CardHeader("One")]),
Card([CardHeader("Two")]),
Card([CardHeader("Three")])
], { columns: 3, gap: "md" }))
Span mode — GridItem on a 12‑track grid
Wrap children in GridItem(child, { span }) to give them
explicit widths on a 12‑column track. span is a number
(1–12) or a fraction ("1/2", "1/3",
"3/4"). Any GridItem child turns on the 12‑track
grid automatically — this is the canonical sidebar‑plus‑main layout.
Three more props round it out: offset (0–11) leaves empty columns
before the item, rowSpan makes one cell tall next to a stack of shorter
ones, and spanAt is a responsive span map like
{ sm: 12, md: 6, lg: 4 }.
With no span at all the item takes a single cell, so wrapping children in
GridItem is safe even inside a Grid({ columns: N }).
$app(Grid([
GridItem(Card([CardHeader("Side"), Text("span 1/4")]), { span: "1/4" }),
GridItem(Card([CardHeader("Main"), Text("span 3/4")]), { span: "3/4" })
], { gap: "lg" }))
Center — both axes at once
Center is the easy way to drop a spinner, empty state, or hero
call‑to‑action into the middle of a region. Give it
minHeight to reserve vertical space, or axis to
center on only one axis.
$app(Card([Center([
EmptyState("No messages yet", { description: "Start a conversation to see it here.", icon: "inbox" })
], { minHeight: "240px" })]))
Responsive layouts
Any of gap, align, justify,
padding, Grid's columns, and
Stack's direction accept a responsive map keyed by
breakpoint: base (mobile‑first),
sm 640px, md 768px, lg 1024px,
xl 1280px. Use Stack (not Row/Column)
when the direction itself must change — e.g. a sidebar that
stacks on mobile and sits beside the content on desktop.
$app(Grid([
Card([CardHeader("Tile 1")]), Card([CardHeader("Tile 2")]),
Card([CardHeader("Tile 3")]), Card([CardHeader("Tile 4")])
], { columns: { base: 1, sm: 2, lg: 4 }, gap: "md" }))
$app(Stack([
Card([CardHeader("Nav"), Text("Column on mobile, beside the content on desktop.")]),
Card([CardHeader("Content"), Text("Stack is the escape hatch for responsive direction.")])
], { direction: { base: "column", md: "row" }, gap: "md" }))
Spacing & surfaces
Container([…], { size }) centers a wide page within a
comfortable reading width (sm/md/lg/xl/full).
Box([…], { padding, margin, background, border, radius, maxWidth })
is a plain spacing/surface wrapper for when a full Card is too heavy.
Spacer() flexes to push siblings apart inside a Row;
with a size it becomes a fixed gap.
Box’s border is
none (default) / subtle / default
and its background is one of
none · surface · muted · primary · success · warning · danger · info.
radius
(none · sm · md · lg · pill)
rounds the surface independently of the border, so a borderless tinted box can
still be rounded. padding and margin both accept a responsive
map.
$app(Column([
Box([Text("A subtle inset surface — lighter than a Card.")], { padding: "md", background: "muted", border: "subtle" }),
Box([Text("Tinted, borderless, rounded — radius is independent of border.")], { padding: "md", background: "primary", radius: "lg" })
], { gap: "md" }))
Cards, headers & card sections
Card([…], { variant, padding, onClick?, href? }) is the default
surface for one chunk of a page. Inner padding defaults to lg; set
padding: "none" when the body is a Table, an image, or a list
that should meet the card’s edges.
Pass onClick or href and the whole card becomes a single
interactive target. It then renders as a real <button> or
<a>, so it is keyboard‑operable without any wrapper of yours.
| Piece | Signature | Reach for it when… |
|---|---|---|
CardHeader | ({ title, eyebrow, subtitle, actions, level }) | The card needs a title. eyebrow is the small line above the title (category, kicker); actions pins nodes to the trailing edge of the title row; level (2–6, default 3) keeps the heading outline sane on a page full of cards. |
CardSection | ([…], { tone, align }) | One region of the card needs its own semantic tint — a full‑bleed band with a rule above and below. |
CardFooter | ([…], { justify }) | The card ends in actions. Trailing‑aligned by default; justify: "between" gives you “destructive far left, confirm far right”. |
CardSection tones are
default · activating · success · warning · critical · neutral · corporate · promoting,
and align is left/center/right.
Leave the Card on its normal padding — the band handles its own
bleed.
$app(Card([
CardHeader("example.com", { eyebrow: "Hosting", subtitle: "Renews 4 March 2027", actions: [Pill("SSL active", { tone: "success", icon: "lock" })] }),
CardSection([Text("Certificate renews in 12 days.")], { tone: "activating" }),
CardSection([Text("Payment method expired.")], { tone: "critical" }),
CardFooter([Button("Remove", { variant: "ghost" }), Button("Renew", { variant: "primary" })], { justify: "between" })
]))
// no padding prop — the Card keeps its default `lg`
The tint runs to the card’s edges while the band’s text stays aligned with the header, because the band bleeds out by exactly the card’s padding and then re‑insets its own content by the same amount.
Do not combine CardSection with padding: "none"
The bleed is computed from the card’s own padding. Set the card
to padding: "none" and that value is zero, so the band has nothing to
bleed out of and no inset of its own — its text ends up flush against
the card’s border. Use padding: "none" for a full‑bleed
Table or image body, and normal padding when the card holds
CardSection bands.
CardSection, a nested Card, or a Callout?
A region of the same object → CardSection.
It is a band inside the card you already have, and it inherits the card’s
border and corners.
A nested Card says “this is a separate
object” and costs a second border and a second padding box — right for a
list of things, wrong for one region of one thing. A
Callout is a standalone bordered notice that works
anywhere on the page, card or no card.
$picked = "nothing yet"
$app(Column([
Grid([
Card([Column([CardHeader("Quarterly report"), Text("The whole card is one button.")], { gap: "xs" })], { onClick: () => $picked = "the report" }),
Card([Column([CardHeader("Changelog"), Text("This one renders as a link instead.")], { gap: "xs" })], { href: "#changelog" })
], { columns: 2, gap: "md" }),
Text(`You clicked: ${$picked}`, { variant: "small", tone: "muted" })
], { gap: "md" }))
onClick renders a <button>; href
renders an <a>. Do not put another button or link inside a
clickable card — nested interactive elements are invalid HTML and the
inner click also fires the card.
ScrollArea — a bounded scrolling pane
ScrollArea clips a long list, log, or chat transcript into a pane
with a clean scrollbar instead of letting it stretch the page. Its two size
props are not synonyms — that is the whole point of having both.
| Prop | Effect |
|---|---|
maxHeight | The pane grows with its content up to this cap, then scrolls. Default 320px. |
height | A fixed box that neither grows nor shrinks with its content — use it when the pane must not resize as rows arrive. |
direction | vertical · horizontal · both. |
stickToBottom | Keeps the newest content in view as it is appended — until the user scrolls up, which cancels the follow. |
$app(Column([
ScrollArea([Column([
Text("height: a fixed 120px box — it never grows with its content."),
Text("line 2"), Text("line 3"), Text("line 4"), Text("line 5"), Text("line 6")
], { gap: "xs" })], { height: "120px" }),
ScrollArea([Column([
Text("maxHeight: grows with its content, then scrolls at 120px."),
Text("line 2")
], { gap: "xs" })], { maxHeight: "120px" })
], { gap: "md" }))
The first pane is 120px tall whether it holds one line or twenty. The second is as tall as its two lines and only starts scrolling once it would exceed 120px.
Upgrading: height changed meaning
height used to be an alias for maxHeight. It is now
a prop in its own right, so
ScrollArea([…], { height: "300px" }) that used to mean
“grow up to 300px” now means “always exactly 300px”.
Nothing errors — the pane just stops hugging short content. Rename it to
maxHeight to keep the old behaviour.
Joined controls & action rows
A Row of controls and a single composite control are different
things. A Row puts a gap between siblings that each stand alone;
these three remove the gap and draw one shared shell, so the group reads —
and is announced — as one control.
| Component | Signature | What it is, and what to use instead |
|---|---|---|
ButtonGroup | (items, { size, fullWidth, ariaLabel }) | Buttons joined edge‑to‑edge with shared borders; only the outer corners round. For a single‑select pill track use SegmentedControl; for spaced‑out independent actions use Buttons. |
InputGroup | (field, { icon, action, suffix, label, hint, error, required, … }) | One bordered shell and one focus ring around a field plus its leading icon and trailing button — search boxes, password reveal, copy rows, unit suffixes like "GB". |
ActionStripe | (label, { icon, description, value, trailing, href, onClick, target, disabled }) | A full‑width clickable row ending in a chevron — settings screens, product menus, drill‑down lists. Use ListItem for a row that is not a navigation target. |
Always give ButtonGroup an ariaLabel: it renders
role="group", and a group with no accessible name is announced as an
anonymous one. size and fullWidth apply to every button
in the group, so set them on the group rather than on each child.
$q = ""
$notify = true
$app(Column([
InputGroup(Input({ id: "search", value: $q, placeholder: "example.com" }), {
icon: "magnifying-glass",
label: "Find a domain",
hint: "One shared border, one focus ring.",
action: Button("Search", { variant: "primary" })
}),
ButtonGroup([Button("Day"), Button("Week"), Button("Month")], { size: "sm", ariaLabel: "Time range" }),
Card([
ActionStripe("Domains", { icon: "globe", description: "3 active", value: "Manage", href: "#domains" }),
ActionStripe("Email", { icon: "envelope", description: "12 mailboxes", trailing: Badge("New", { tone: "primary" }) }),
ActionStripe("Notifications", { icon: "bell", description: "Weekly digest", trailing: Switch("digest", { value: $notify, onChange: (next) => $notify = next }) })
], { padding: "none" })
], { gap: "md" }))
value is a short trailing string and trailing is a node
— a Switch, Badge, or Avatar. Pick one per
row: both render if you pass both. A click on the trailing node does
not fire the row’s own onClick, so flipping the
toggle above never navigates.
href alone decides the tag: with it the row is an
<a>, without it a <button>, and
target: "_blank" also sets rel="noopener noreferrer". That
holds even when the row is disabled, because swapping the tag mid‑interaction
would destroy focus.
Each stripe carries its own padding and a hairline under it, which is why the wrapping
Card gets padding: "none" here — the rows are meant to
run to the card’s edges, and the last one drops its hairline automatically.
Section — full-width page bands
Section([…], { background, width, padding, align, eyebrow?, title?, subtitle?, actions?, id? })
is a marketing‑style band: a full‑bleed background with its content
centered to a comfortable width. Stack a few to build a landing page — it is
equally at home on docs and settings pages.
| Prop | Values |
|---|---|
background | base · soft · surface · muted · brand |
width | sm · md · lg · xl · full — the inner content width, not the band |
padding | none · xs · sm · md (default) · lg · xl |
align | left · center — centers the generated header only; children keep their own alignment |
actions | Nodes aligned opposite the title, e.g. a “See all” button |
id | Anchor for in‑page navigation — pairs with ScrollSpy and TableOfContents |
$app(Column([
Section([
Button("Get started", { variant: "primary" })
], { background: "brand", eyebrow: "New", title: "Ship UIs in one language", subtitle: "A band that spans full width and centers its content." }),
Section([
Grid([Card([Text("Fast")]), Card([Text("Reactive")]), Card([Text("Themeable")])], { columns: 3, gap: "md" })
], { background: "soft", eyebrow: "Why Aktion", title: "Features", actions: [Button("See all", { variant: "ghost" })] })
]))
Each band paints its background across the full width of its container while the header and children stay inside the centered content column.
Split — two panes
Split(left, right, { ratio, gap, divider, sticky, stickyOffset, stackAt, reverseOnStack, align })
places two panes side by side — the canonical “text + media”,
“code + preview”, and “content + sidebar” section.
ratio is a named split ("1/1", "2/3",
"1/3"…) or any a/b pair such as
"60/40".
sticky: "left" | "right" pins one pane while the other scrolls, and
stickyOffset is how far below the viewport top it pins. Set it to your
header height — the default is 88px, and too small a value hides the
pane underneath a fixed header.
stackAt is where the two panes collapse into one column:
sm 640px, md 768px (the default), or
lg 1024px. reverseOnStack puts the right pane first once
stacked — the usual want for media‑above‑text on mobile.
$app(Split(
Column([CardHeader("Docs"), Text("Long-form content on the left.")]),
Card([CardHeader("On this page"), Text("A sticky aside on the right.")]),
{ ratio: "2/3", divider: true, stackAt: "md", sticky: "right", stickyOffset: "88px" }
))
Below 768px the two panes become one column, and the divider and the sticky pin are both dropped — pinning inside a single‑column flow would just park the aside on top of the content.
Bento — named-span grid
Bento([…], { columns, gap, rowHeight, dense }) of
BentoCell(child, { span, rowSpan }) builds an editorial “bento
box” grid where cells claim different column and row spans — the marquee
feature‑section layout.
span takes a name (tile 1×1, wide 2×1,
tall 1×2, hero 2×2, full for a whole
row), a "CxR" string such as "2x1", a bare column count, or
{ col, row }.
Two rules decide whether it looks designed or ragged. Spans must tile the grid
exactly — each row’s column spans should sum to
columns, and row spans should pair up with their neighbours so no track is
left dangling.
And set a fixed rowHeight whenever a cell spans rows or
holds an image, because the default auto row stretches to the tallest cell. Give one or
two cells a big span and keep the rest tiles.
$app(Bento([
BentoCell(Card([CardHeader("Hero"), Text("span: \"hero\" — 2 x 2")]), { span: "hero" }),
BentoCell(Card([CardHeader("Tall"), Text("span: \"tall\" — 1 x 2")]), { span: "tall" }),
BentoCell(Card([CardHeader("Wide"), Text("span: \"wide\" — 2 x 1")]), { span: "wide" }),
BentoCell(Card([Text("tile")]))
], { columns: 3, gap: "md", rowHeight: "150px" }))
Rows 1–2 are filled by hero (2 columns) plus tall
(1 column); row 3 by wide (2) plus the tile (1). Nothing is left over,
which is why the mosaic reads as intentional.
The grid collapses to 2 columns below 920px and 1 below 640px, so a
rowHeight that suits three columns is usually too short for one. Pass a
responsive map like { base: "220px", md: "150px" } when it matters.
Overlay & Fragment
Overlay(base, [OverlayItem(child, { anchor, offset })]) layers content
absolutely over a base node — corner badges, sale ribbons, play buttons over
thumbnails, captions.
anchor names the corner, edge, or centre:
top-left · top-right ·
bottom-left · bottom-right ·
top · bottom · left ·
right · center. offset is the inset from
that edge as a CSS length ("8px").
Fragment([…]) is the opposite: it groups siblings without
adding a layout box at all. The children become direct children of the parent and take
part in its flex or grid layout exactly as if you had written them inline.
Use it to return several nodes from one component, or to group siblings behind a
condition, without a stray wrapper that would break a Grid’s spacing.
Give a Fragment an sx that needs a box and one host element is
created for it (how that works) — but reach for a
Box when you actually want a box, so the code says so.
This is not the popup layer. Overlay positions a
badge inside its own base node. Menus, tooltips and dialogs that must
escape their container entirely are handled by the
floating layer — you do not compose those by hand.
$app(Overlay(
Card([Image({ src: "https://picsum.photos/400/200", alt: "Cover" })]),
[
OverlayItem(Badge("Sale", { tone: "danger" }), { anchor: "top-right" }),
OverlayItem(Text("Featured", { variant: "small-heavy" }), { anchor: "bottom-left" })
]
))
The sx styling channel
Every component accepts a universal sx prop — a
bounded, theme‑safe style intent (not raw CSS). Values are
token references, enums, or sanitised scalars, so it stays XSS‑safe and
LLM‑enumerable. sx also accepts responsive maps and
interaction states. The patterns below are the layout‑relevant subset —
the sx reference documents every key.
| Pattern | Meaning |
|---|---|
sx: { p: "l", bg: "surface", radius: "lg" } | Spacing, background, and radius from theme tokens. |
sx: { px: "xl", ps: "m", me: "auto" } | Logical spacing — px/mx emit padding-inline/margin-inline and ps/pe/ms/me set the inline start/end sides, so RTL apps mirror automatically. |
sx: { width: { base: "100%", md: "50%" } } | Responsive map → real @media rules at sm/md/lg/xl. Works on every key (spacing, color, flex, position, typography, …). |
sx: { fontSize: "2xl", weight: "700", textDecoration: "underline" } | Bounded typography — fontSize token ramp (xs…4xl) or a length, weight 100–900. |
sx: { states: { hover: { scale: 1.03, shadow: "lg" } } } | Interaction states: hover/focus/active/disabled/focus-visible/checked/group-hover. |
sx: { p: "safe", minHeight: "dvh" } | Safe‑area insets (safe/safe-top/…) and dynamic viewport units. |
sx: { bg: "gradient.brand" } | A named theme gradient as the background. |
sx: { bgImage: "/hero.jpg", bgOverlay: "rgba(0,0,0,.4)" } | Background image (http(s)/relative/data:image URLs only) with an optional color or gradient.* overlay wash — hero banners with readable text. |
sx: { position: "sticky", top: 0, zIndex: "modal" } | Positioning + layer tokens — zIndex names resolve through themeable --rui-z-* vars ($theme({ zIndex: {…} })). |
animate: "fade-up" · animate: { preset: "zoom", delay: 150 } | Sibling motion prop on every component — presets like fade/fade-up/zoom/slide-*/pulse/float. The object form tunes delay/duration/repeat. Auto‑respects prefers-reduced-motion. |
$app(Grid([
Card([Text("Hover me")], { sx: { p: "l", states: { hover: { scale: 1.04, shadow: "lg" } } } }),
Card([Text("Gradient")], { sx: { p: "l", bg: "gradient.cool", color: "#fff" } }),
Card([Text("Half on md+")], { sx: { p: "l", width: { base: "100%", md: "60%" } } })
], { columns: 3, gap: "md" }))
Sticky & its stuck state
Sticky([…], { side, offset, zIndex }) wraps its children in a
position: sticky container so they pin to an edge of the nearest
scrollable ancestor — a toolbar above a table, an in‑page nav, a status
banner, or a pinned first column in a horizontally scrolling row.
| Prop | Values |
|---|---|
side | top · bottom · left · right |
offset | CSS length, default 0 — how far from that edge it pins |
zIndex | Number, default 10 — raise it if the pinned bar is overlapped by later content |
Sticky(
Row([Text("Title", { variant: "large-heavy" }), Spacer(), Button("Action")]),
{ side: "top", offset: "0" }
)
// while pinned the node carries data-stuck="true"
Once pinned the wrapper sets data-stuck="true" on itself, so a CSS
hook — a shadow, a hairline, a condensed height — can react to the
pinned state without any JavaScript of yours.
/* in your app stylesheet */
.rui-sticky[data-stuck="true"] { box-shadow: 0 2px 12px rgba(0, 0, 0, .12); }
Sticky positioning is relative to the nearest scrolling ancestor, so a
Sticky inside a ScrollArea pins to that pane, not to the
page.
Popups, menus & the top layer
An anchored surface — a Tooltip, a DropdownMenu, a
Combobox list — is not laid out by its parent. A shared floating layer
measures each panel against its trigger and promotes it into the browser’s
top layer.
That is what lets a panel escape the two things that used to break it: an ancestor that
clips overflow, and an ancestor that wins on z-index.
There is nothing to opt into. It matters because it makes the obvious composition
safe: a row‑actions menu inside a scrolling Table, a filter
Popover inside a Modal, a Combobox inside an
Accordion item or an InputGroup.
Roughly two dozen containers in the stylesheet set a non‑visible
overflow, and overflow-x: auto clips vertically as well. So a
panel opening downward used to be amputated at the container edge — and one
opening upward was unreachable, because no scrollbar can reveal overflow above the top
edge.
$app(ScrollArea([Column([
Card([Row([
Text("invoice-2401.pdf"),
Spacer(),
DropdownMenu({
trigger: IconButton("ellipsis", { label: "Row actions", variant: "ghost" }),
items: [MenuItem("Rename"), MenuItem("Duplicate"), MenuSeparator(), MenuItem("Delete", { variant: "danger" })],
align: "end",
label: "Row actions"
})
])], { padding: "sm" }),
Card([Row([
Text("invoice-2402.pdf"),
Spacer(),
Tooltip({ trigger: IconButton("download", { label: "Download", variant: "ghost" }), label: "Download the PDF", side: "left" })
])], { padding: "sm" }),
Card([Text("The pane clips at 150px, but the menu does not.")], { padding: "sm" })
], { gap: "sm" })], { height: "150px" }))
The menu paints outside the 150px pane and stays anchored to its trigger as you scroll. The same markup used to clip the menu at the pane’s edge, which is why row‑actions menus were a recurring bug rather than a one‑liner.
Which components float
These are the components whose panel is positioned and promoted for you. The placement column lists the props you control; everything else is measured.
| Component | Placement props | Notes |
|---|---|---|
Tooltip | side, align, delay | Centred on the trigger edge by default, and never given an internal scrollbar — a scrolling hint reads as broken. |
HoverCard | side, align, width | A tall card is height‑capped and scrolls internally. |
Popover | side, align, width | Click‑triggered; keeps outside‑click and Escape dismissal. |
DropdownMenu | side, align | The row‑actions and overflow‑menu workhorse. |
ContextMenu | placement, offset | Anchored to the pointer, not to an element, so a right‑click near the bottom or right edge still lands fully on screen. |
Combobox · MultiSelect | — | Always open below the trigger and match its width, with a 180px floor so a narrow trigger in a table cell still produces a readable list. |
MentionInput | — | Suggestions anchor to the whole textarea, not to the caret. |
NotificationBell | align (left/right) | Opens below the bell. |
Select is deliberately absent: it renders a native
<select>, so the browser owns its popup and always has.
Placement, flipping and the height cap
side is the side of the trigger the panel prefers
(top / bottom / left / right,
default bottom); align is how it lines up along that edge
(start / center / end, default
start).
Both are preferences, not promises: the layer resolves them against the viewport in a fixed order.
- Flip the side — but only when your side genuinely lacks room and the opposite side has more. Otherwise your choice is kept and the next two steps absorb the overflow, so a panel never bounces between sides on a marginal fit.
- Align along the chosen edge.
- Shift back inside the viewport, keeping 8px clear of every edge.
- Cap the height. A panel taller than the space on the chosen side
gets a
max-height(never below 96px) and scrolls internally, so the last menu item is always reachable.
The gap between trigger and panel is 6px. Positioning re-runs on scroll (including
the scroll of inner containers), on resize, and whenever the panel or its anchor
changes size — filtering a Combobox list down to two rows
re‑pins it immediately.
The data-floating-side CSS hook
Once positioned, the panel carries
data-floating-side="top|bottom|left|right" — the side that was
actually used, after any flip. Style against it exactly the way you style
data-stuck: point an arrow, or move a shadow, so
the panel still looks right when it flipped out from under your preferred
side.
/* in your app stylesheet */
.rui-dropdown-menu-content[data-floating-side="top"] { box-shadow: 0 -8px 24px rgba(0, 0, 0, .18); }
The attribute is also a contract with the renderer. While it is present, a re-render
leaves the panel’s style and its popover attribute
alone — which means an open panel’s root cannot be restyled by a
commit. Ownership returns automatically on close, when the layer removes
the attribute and restores the style it saved.
Full‑screen overlays
Modal, Sheet, BottomSheet,
ConfirmDialog, SpeedDial and Confetti use the
promotion half of the same layer with no anchoring at all: they place themselves with
position: fixed and only need to escape a containing block.
CommandPalette and Lightbox promote themselves the same way
through their own code path.
While one is open the page behind it is scroll‑locked, reference‑counted so nested overlays hand page scrolling back exactly once.
“Could not be promoted to the browser top layer”
An ancestor is trapping your overlay. Where the Popover API is
unavailable the layer falls back to plain position: fixed, which any
ancestor with a transform, filter,
backdrop-filter, perspective, will-change, or
contain: paint|layout|strict|content turns into its containing block.
The “full‑screen” scrim then collapses into that element’s box.
The console warning names the exact ancestor by tag and class. The usual culprit is an
animate preset or an sx transform on a wrapper: remove it, or
move the overlay out of it. A standalone Toast is not promoted at all, so
this applies to it in every browser.
Recipes for complex layouts
Dashboard — header, KPI strip, content grid
$app(Column([
PageHeader("Sales", { subtitle: "Q4 2026", actions: [Button("Export", { variant: "ghost", icon: "download" })] }),
Grid([
StatCard("MRR", { value: "$48.2k", trend: "up", delta: "+12%" }),
StatCard("Deals", { value: "318", trend: "up", delta: "+24" }),
StatCard("Win rate", { value: "31%", trend: "down", delta: "-2%" })
], { gap: "md" }),
Grid([
GridItem(Card([CardHeader("Pipeline"), Text("Main chart goes here.")]), { span: "2/3" }),
GridItem(Card([CardHeader("Tasks"), Text("Side panel.")]), { span: "1/3" })
], { gap: "lg" })
], { gap: "lg" }))
Holy‑grail / app shell — header, sidebar, content
For a real multi‑page app prefer the dedicated
AppShell(sidebar, content) pattern. To assemble it by hand,
nest a Row inside a Column:
$app(Column([
Card([Row([Text("Acme", { variant: "large-heavy" }), Spacer(), Badge("Pro", { tone: "primary" })])]),
Grid([
GridItem(Card([CardHeader("Menu"), Text("Sidebar nav")]), { span: "1/4" }),
GridItem(Column([
Card([CardHeader("Welcome back")]),
Card([CardHeader("Recent activity")])
], { gap: "md" }), { span: "3/4" })
], { gap: "lg" })
], { gap: "md" }))
Centered auth card
$app(Center([
Box([Column([
CardHeader("Sign in", { subtitle: "Welcome back" }),
Input("email", { placeholder: "you@example.com" }),
Input("password", { placeholder: "Password", type: "password" }),
Button("Continue", { variant: "primary" })
], { gap: "md" })], { padding: "lg", background: "surface", border: "default", maxWidth: "360px" })
], { minHeight: "70vh" }))
Cheat sheet
| Goal | Snippet |
|---|---|
| Page body | Column([…], { gap: "lg" }) |
| Toolbar (title left, actions right) | Row([title, Spacer(), …buttons]) |
| Even button row | Row([…], { gap: "sm" }) |
| Equal-width cards in a row | Grid([…], { columns: 3 }) |
| Reflowing KPI / card wall | Grid([…], { minChildWidth: "200px" }) |
| Sidebar + main | Grid([GridItem(s, { span: "1/4" }), GridItem(m, { span: "3/4" })]) |
| Expand one child in a row | Row([StackItem(field, { grow: 1 }), button]) |
| Center on both axes | Center([…], { minHeight: "60vh" }) |
| Centered reading column | Container([…], { size: "md" }) |
| Stack on mobile, row on desktop | Stack([…], { direction: { base: "column", md: "row" } }) |
| Padding / inset surface | Box([…], { padding: "md", background: "muted" }) |
| Full-width page band | Section([…], { background: "soft", title: "…" }) |
| Two panes (content + aside) | Split(left, right, { ratio: "2/3", divider: true }) |
| Editorial bento grid | Bento([BentoCell(c, { span: 2 }), …]) |
| Badge over an image | Overlay(base, [OverlayItem(b, { anchor: "top-right" })]) |
| Group nodes without a box | Fragment([a, b, c]) |
| Tinted band inside a card | CardSection([…], { tone: "critical" }) |
| Full-bleed card body (table, image) | Card([…], { padding: "none" }) |
| Whole card as one control | Card([…], { href: "#/detail" }) |
| Buttons joined into one control | ButtonGroup([…], { ariaLabel: "View" }) |
| Field + icon + trailing button | InputGroup(field, { icon, action }) |
| Settings / drill-down row | ActionStripe("Domains", { icon, value, href }) |
| Scrolling log that follows new lines | ScrollArea([…], { height: "240px", stickToBottom: true }) |
| Pinned toolbar above a table | Sticky([…], { side: "top", offset: "0" }) |
| One-off style intent | { sx: { p: "l", bg: "surface", states: { hover: { scale: 1.03 } } } } |
Next
Component catalog
Every component, prop, and a live preview — including all the layout primitives.
Browse components → Try itPlayground
Paste any snippet above and tweak it live with autocomplete and inspect mode.
Open the playground → ReferenceLanguage
State, expressions, control flow, and the rules behind component calls.
View reference →