Core concepts

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 gridGrid([…], { columns })
Content centered on both axes (spinner, empty state, hero CTA)Center([…], { minHeight })
A 12‑column dashboard with mixed widthsGrid + GridItem(child, { span })
A centered, max‑width reading columnContainer([…], { size })
Direction that flips on mobile vs desktopStack([…], { direction: {base, md} })
A tinted, full‑bleed band inside a cardCardSection([…], { tone })
Buttons that should read as one controlButtonGroup([…])
A long list or log clipped to a scrolling paneScrollArea([…], { maxHeight })
A menu or popover that must escape a clipping parentnothing — 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.

Live
$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".

Live
$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.

Live
$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).

Live
$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:

ModeHowBest for
Auto‑fit (default)omit columnsCard / KPI walls that reflow on their own — wraps as many ≥ minChildWidth columns as fit.
Fixedcolumns: N (1–12)Exactly N equal columns.
SpanGridItem children12‑track dashboards & sidebars with mixed widths.

Auto‑fit — reflows by itself

Live
$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

Live
$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 }).

Live
$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.

Live
$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.

Live — resize the window / preview
$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" }))
Live — direction flips at 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.

Live
$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.

PieceSignatureReach 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.

Live — eyebrow, two tinted bands, split footer
$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.

Live — a whole card as one control
$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.

PropEffect
maxHeightThe pane grows with its content up to this cap, then scrolls. Default 320px.
heightA fixed box that neither grows nor shrinks with its content — use it when the pane must not resize as rows arrive.
directionvertical · horizontal · both.
stickToBottomKeeps the newest content in view as it is appended — until the user scrolls up, which cancels the follow.
Live — fixed box vs. grow‑then‑scroll
$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.

ComponentSignatureWhat 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.

Live — one shell, one focus ring, one row per setting
$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.

PropValues
backgroundbase · soft · surface · muted · brand
widthsm · md · lg · xl · full — the inner content width, not the band
paddingnone · xs · sm · md (default) · lg · xl
alignleft · center — centers the generated header only; children keep their own alignment
actionsNodes aligned opposite the title, e.g. a “See all” button
idAnchor for in‑page navigation — pairs with ScrollSpy and TableOfContents
Live
$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.

Live
$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.

Live — four cells tiling a 3‑column grid exactly
$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.

Live
$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.

PatternMeaning
sx: { p: "l", bg: "surface", radius: "lg" }Spacing, background, and radius from theme tokens.
sx: { px: "xl", ps: "m", me: "auto" }Logical spacingpx/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.
Live — hover a card
$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.

PropValues
sidetop · bottom · left · right
offsetCSS length, default 0 — how far from that edge it pins
zIndexNumber, 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.

Live — open the menu, then scroll the pane
$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.

ComponentPlacement propsNotes
Tooltipside, align, delayCentred on the trigger edge by default, and never given an internal scrollbar — a scrolling hint reads as broken.
HoverCardside, align, widthA tall card is height‑capped and scrolls internally.
Popoverside, align, widthClick‑triggered; keeps outside‑click and Escape dismissal.
DropdownMenuside, alignThe row‑actions and overflow‑menu workhorse.
ContextMenuplacement, offsetAnchored to the pointer, not to an element, so a right‑click near the bottom or right edge still lands fully on screen.
Combobox · MultiSelectAlways 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.
MentionInputSuggestions anchor to the whole textarea, not to the caret.
NotificationBellalign (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.

  1. 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.
  2. Align along the chosen edge.
  3. Shift back inside the viewport, keeping 8px clear of every edge.
  4. 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

Live
$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:

Live
$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

Live
$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

GoalSnippet
Page bodyColumn([…], { gap: "lg" })
Toolbar (title left, actions right)Row([title, Spacer(), …buttons])
Even button rowRow([…], { gap: "sm" })
Equal-width cards in a rowGrid([…], { columns: 3 })
Reflowing KPI / card wallGrid([…], { minChildWidth: "200px" })
Sidebar + mainGrid([GridItem(s, { span: "1/4" }), GridItem(m, { span: "3/4" })])
Expand one child in a rowRow([StackItem(field, { grow: 1 }), button])
Center on both axesCenter([…], { minHeight: "60vh" })
Centered reading columnContainer([…], { size: "md" })
Stack on mobile, row on desktopStack([…], { direction: { base: "column", md: "row" } })
Padding / inset surfaceBox([…], { padding: "md", background: "muted" })
Full-width page bandSection([…], { background: "soft", title: "…" })
Two panes (content + aside)Split(left, right, { ratio: "2/3", divider: true })
Editorial bento gridBento([BentoCell(c, { span: 2 }), …])
Badge over an imageOverlay(base, [OverlayItem(b, { anchor: "top-right" })])
Group nodes without a boxFragment([a, b, c])
Tinted band inside a cardCardSection([…], { tone: "critical" })
Full-bleed card body (table, image)Card([…], { padding: "none" })
Whole card as one controlCard([…], { href: "#/detail" })
Buttons joined into one controlButtonGroup([…], { ariaLabel: "View" })
Field + icon + trailing buttonInputGroup(field, { icon, action })
Settings / drill-down rowActionStripe("Domains", { icon, value, href })
Scrolling log that follows new linesScrollArea([…], { height: "240px", stickToBottom: true })
Pinned toolbar above a tableSticky([…], { side: "top", offset: "0" })
One-off style intent{ sx: { p: "l", bg: "surface", states: { hover: { scale: 1.03 } } } }

Next