Layout
Columns, grids, flow, surfaces, and dividers. Every width and step resolves to the active preset's tokens, so the same markup follows the preset you pick above.
Container
width · gutter — centres and caps a column
The width caps the content; gutters sit outside it. The shaded band is --gutter. A measure column stays a readable number of characters however
wide the gutter is. Content and page are wider than this frame, so they fill it.
Every cap is a token. --narrow-max, --measure, --content-max, --page-max, and --gutter live in each
preset's space.css.
Source src/lib/components/layout/container/doc.ts · src/lib/components/layout/container/Container.svelte · src/lib/components/layout/container/container.variants.ts · src/lib/components/layout/container/container.module.css
src/lib/components/layout/container/doc.ts
/**
* Container — centres content and caps its width.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* width? "narrow" | "measure" | "content" | "page" default "page"
* gutter? boolean default true
* …every attribute of a div, and a ref
*
* # Behaviour
*
* R1 Renders one div, centred in its parent's inline direction. It fills
* its parent up to its cap in any parent: block, flex, or grid.
* R2 Each width is a cap, never a fixed size: in a parent narrower than the
* cap, the container is as wide as the parent allows.
* narrow `--narrow-max` a single focused task: sign-in, a short form
* measure `--measure` a reading column, capped in characters
* content `--content-max` a document or settings page
* page `--page-max` the full working width of an app screen
* R3 The width caps the CONTENT. With `gutter`, `--gutter` is added on both
* inline sides outside that cap, so a measure stays a measure however wide
* the gutter is.
* R4 Without `gutter` the content touches the container's edges; use it when
* the parent already provides side padding.
* R5 All four caps and the gutter are tokens, so each preset may retune them.
*
* # Deliberately absent
*
* Vertical padding, a background, and breakpoint props. A container places a
* column; what fills the column is its children's business.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* R3 is a border-box width of 100% capped at `cap + 2 × gutter`, with the
* gutter as padding: the content box can never exceed the cap, and the
* gutter never eats into it. The width is explicit because a container is
* often a grid or flex child, where auto margins alone shrink an item to its
* content. Centring is `margin-inline: auto` — the one margin in the layout
* group, and the reason the group rule on margins names an exception.
*/
export {};src/lib/components/layout/container/Container.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import { containerVariants, type ContainerVariants } from './container.variants';
export type ContainerProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
ContainerVariants & {
class?: string;
children?: Snippet;
};
let { children, width, gutter, class: className = '', ...rest }: ContainerProps = $props();
</script>
<div {...rest} class={cn(containerVariants({ width, gutter }), className)}>
{@render children?.()}
</div>src/lib/components/layout/container/container.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
import styles from './container.module.css';
export const containerVariants = cva(styles.root, {
variants: {
width: {
narrow: styles.narrow,
measure: styles.measure,
content: styles.content,
page: styles.page
},
gutter: { true: styles.gutter, false: null }
},
defaultVariants: { width: 'page', gutter: true }
});
export type ContainerVariants = VariantProps<typeof containerVariants>;src/lib/components/layout/container/container.module.css
@layer primitive {
.root {
--container-gutter: 0px;
box-sizing: border-box;
/* Fill the parent, then cap. A width of 100% matters in a grid or flex
parent, where auto margins alone would shrink the container to fit its
content. */
width: 100%;
min-width: 0;
/* The cap is the CONTENT width; gutters are added outside it, so a
reading measure stays a reading measure however wide the gutter is. */
max-width: calc(var(--container-max) + 2 * var(--container-gutter));
margin-inline: auto;
padding-inline: var(--container-gutter);
}
.narrow {
--container-max: var(--narrow-max);
}
.measure {
--container-max: var(--measure);
}
.content {
--container-max: var(--content-max);
}
.page {
--container-max: var(--page-max);
}
.gutter {
--container-gutter: var(--gutter);
}
}Grid
columns · min · gap — fixed, fluid, or both
Resize the window. columns alone never changes. min alone adds columns while they fit. Both together means “up to four, fewer when
a cell would get narrower than 12rem”, which is the usual card grid.
Empty tracks are kept. A lone item in a wide fluid grid stays one column wide instead of stretching across the row.
Source src/lib/components/layout/grid/doc.ts · src/lib/components/layout/grid/Grid.svelte · src/lib/components/layout/grid/grid.variants.ts · src/lib/components/layout/grid/grid.module.css
src/lib/components/layout/grid/doc.ts
/**
* Grid — children in equal columns, fixed or fitted to the space.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* columns? 1 | 2 | 3 | 4 | 5 | 6
* min? a length in px, rem, em, or ch
* gap? "sm" | "md" | "lg" default "md"
* …every attribute of a div, and a ref
*
* # Behaviour
*
* R1 Neither `columns` nor `min`: one column.
* R2 `columns` alone: exactly that many equal columns at every width.
* R3 `min` alone: as many equal columns as fit without any being narrower
* than `min`. Empty tracks are kept, so a lone item in a wide grid stays
* one column wide rather than stretching across the row.
* R4 Both: as R3, but never more than `columns`. This is the responsive case:
* "up to four across, fewer when a card would get narrower than 14rem".
* R5 In a container narrower than `min`, the single column shrinks to fit
* rather than overflowing.
* R6 Columns are always equal, and a cell's long content wraps or shrinks
* within its column; it never widens its track.
* R7 Gap steps resolve to `--space-3`, `--space-6`, and `--space-8` and apply
* between rows as well as columns — the same steps as Stack.
*
* # Deliberately absent
*
* Spanning, named areas, unequal tracks, and alignment props. A layout that
* needs them is a screen's own grid rule; Grid covers the repeated-card case.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* `columns` and `min` travel as custom properties on the element, and one of
* three classes chooses the template. R4's cap is the track minimum
* `max(min(100%, min), (100% - (n - 1) × gap) / n)`: no track may be narrower
* than an n-column track, so at most n fit. `auto-fill` rather than `auto-fit`
* gives R3's "empty tracks are kept". `min` is typed to four units because a
* percentage or bare number as a track minimum is almost always a mistake.
*/
export {};src/lib/components/layout/grid/Grid.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import { gridVariants, type GridVariants } from './grid.variants';
/** A CSS length a caller can reason about; percentages and bare numbers are
* excluded because a track minimum in either is almost always a mistake. */
export type GridLength = `${number}${'px' | 'rem' | 'em' | 'ch'}`;
export type GridProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
GridVariants & {
class?: string;
children?: Snippet;
/** Exactly this many columns, or with `min`, at most this many. */
columns?: 1 | 2 | 3 | 4 | 5 | 6;
/** The narrowest a column may be; columns are added while they fit. */
min?: GridLength;
};
let { children, columns, min, gap, class: className = '', style, ...rest }: GridProps = $props();
const layout = $derived(min ? (columns ? 'capped' : 'fluid') : 'fixed');
const vars = $derived(
[columns && `--grid-columns: ${columns}`, min && `--grid-min: ${min}`, style]
.filter(Boolean)
.join('; ') || undefined
);
</script>
<div {...rest} class={cn(gridVariants({ gap, layout }), className)} style={vars}>
{@render children?.()}
</div>src/lib/components/layout/grid/grid.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
import styles from './grid.module.css';
export const gridVariants = cva(styles.root, {
variants: {
gap: { sm: styles.sm, md: styles.md, lg: styles.lg },
// Derived from `columns` and `min` by the component, never passed directly.
layout: { fixed: styles.fixed, fluid: styles.fluid, capped: styles.capped }
},
defaultVariants: { gap: 'md', layout: 'fixed' }
});
export type GridVariants = Omit<VariantProps<typeof gridVariants>, 'layout'>;src/lib/components/layout/grid/grid.module.css
@layer primitive {
.root {
display: grid;
min-width: 0;
gap: var(--grid-gap);
}
/* `columns` alone: exactly N equal columns. minmax(0, 1fr) rather than 1fr,
so a long word shrinks its cell instead of widening the track. */
.fixed {
grid-template-columns: repeat(var(--grid-columns, 1), minmax(0, 1fr));
}
/* `min` alone: as many columns as fit, none narrower than `min`. min(100%)
keeps one column from overflowing a container narrower than `min`. */
.fluid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--grid-min)), 1fr));
}
/* Both: fluid, but capped at N. A track can never be narrower than an
N-column track, so at most N fit; it is never narrower than `min` either,
so fewer fit as the container shrinks. */
.capped {
grid-template-columns: repeat(
auto-fill,
minmax(
max(
min(100%, var(--grid-min)),
(100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns)
),
1fr
)
);
}
.sm {
--grid-gap: var(--space-3);
}
.md {
--grid-gap: var(--space-6);
}
.lg {
--grid-gap: var(--space-8);
}
}Stack
direction · gap · wrap
Horizontal centres on the cross axis. A row of controls or labels with different heights shares a centre line; vertical stacks stretch children to the full width instead.
No alignment props. A layout that needs them is a screen's own flex or grid rule. The gap is the only spacing a Stack adds.
Source src/lib/components/layout/stack/doc.ts · src/lib/components/layout/stack/Stack.svelte · src/lib/components/layout/stack/stack.variants.ts · src/lib/components/layout/stack/stack.module.css
src/lib/components/layout/stack/doc.ts
/**
* Stack — children in one direction, with a gap between them.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* direction? "vertical" | "horizontal" default "vertical"
* gap? "sm" | "md" | "lg" default "md"
* wrap? boolean default false
* …every attribute of a div, and a ref
*
* # Behaviour
*
* R1 Renders one flex container; children are laid out in source order.
* R2 `vertical` flows top to bottom and stretches children to its width.
* R3 `horizontal` flows in the inline direction and centres children on the
* cross axis, because a row of controls or labels of different heights
* should share a centre line.
* R4 Gap steps resolve to `--space-3`, `--space-6`, and `--space-8`. The gap
* is the only spacing Stack adds; children keep their own padding.
* R5 With `wrap`, children that do not fit move to a new line, and the gap
* applies between lines as well as between items.
*
* # Deliberately absent
*
* Alignment and justification props. A layout that needs them is a screen's
* own grid or flex rule; a Stack with every flexbox property as a prop is a
* stylesheet with worse ergonomics.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* `vertical` is `flex-direction: column` and relies on the default
* `align-items: stretch`; `horizontal` sets `align-items: center` explicitly.
*/
export {};src/lib/components/layout/stack/Stack.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import { stackVariants, type StackVariants } from './stack.variants';
export type StackProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
StackVariants & {
class?: string;
children?: Snippet;
};
let { children, direction, gap, wrap, class: className = '', ...rest }: StackProps = $props();
</script>
<div {...rest} class={cn(stackVariants({ direction, gap, wrap }), className)}>
{@render children?.()}
</div>src/lib/components/layout/stack/stack.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
import styles from './stack.module.css';
export const stackVariants = cva(styles.root, {
variants: {
direction: { vertical: styles.vertical, horizontal: styles.horizontal },
gap: { sm: styles.sm, md: styles.md, lg: styles.lg },
wrap: { true: styles.wrap }
},
defaultVariants: { direction: 'vertical', gap: 'md' }
});
export type StackVariants = VariantProps<typeof stackVariants>;src/lib/components/layout/stack/stack.module.css
@layer primitive {
.root {
display: flex;
min-width: 0;
}
.vertical {
flex-direction: column;
}
.horizontal {
flex-direction: row;
align-items: center;
}
.sm {
gap: var(--space-3);
}
.md {
gap: var(--space-6);
}
.lg {
gap: var(--space-8);
}
.wrap {
flex-wrap: wrap;
}
}Box
surface · padding — one div, no wrapper
Surface and padding are independent. A panel with no padding is valid, and
so is a plain box with padding; the defaults are plain and none,
so an unconfigured Box is an ordinary div.
Raised is a position, not emphasis. Use it for a surface that sits above the page. Content that only needs to stand out belongs on a panel.
Source src/lib/components/layout/box/doc.ts · src/lib/components/layout/box/Box.svelte · src/lib/components/layout/box/box.variants.ts · src/lib/components/layout/box/box.module.css
src/lib/components/layout/box/doc.ts
/**
* Box — a surface with padding.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* surface? "plain" | "panel" | "raised" default "plain"
* padding? "none" | "sm" | "md" | "lg" default "none"
* …every attribute of a div, and a ref
*
* # Behaviour
*
* R1 Renders exactly one div. It never adds a wrapper around its children.
* R2 `plain` has no appearance: no background, border, radius, or shadow.
* R3 `panel` is the ordinary work surface: `--surface-panel` background, a
* `--line` border, and `--radius-3` corners.
* R4 `raised` is `panel`'s shape on `--surface-raised`, with `--shadow`. It is
* for a surface that sits above the page: a focused task or a popover-like
* region. It is not a way to add emphasis to ordinary content.
* R5 Padding steps resolve to `--space-4`, `--space-6`, and `--space-8`;
* `none` sets no padding.
* R6 Surface and padding are independent: any combination is valid.
*
* # Deliberately absent
*
* Margin, colour, arbitrary spacing values, and a polymorphic element prop.
* A different element with a surface is a job for that element's own class.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* This differs from Flover's Box, which has no appearance at all. Bento's
* presets restyle surfaces heavily (v2's warm cards, v5's bounded panels), and
* a surface primitive gives those tokens one consumer instead of one per
* screen. `plain` is the Flover behaviour, and it is the default.
*/
export {};src/lib/components/layout/box/Box.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import { boxVariants, type BoxVariants } from './box.variants';
export type BoxProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
BoxVariants & {
class?: string;
children?: Snippet;
};
let { children, surface, padding, class: className = '', ...rest }: BoxProps = $props();
</script>
<div {...rest} class={cn(boxVariants({ surface, padding }), className)}>
{@render children?.()}
</div>src/lib/components/layout/box/box.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
import styles from './box.module.css';
export const boxVariants = cva(styles.root, {
variants: {
surface: { plain: null, panel: styles.panel, raised: styles.raised },
padding: { none: null, sm: styles.sm, md: styles.md, lg: styles.lg }
},
defaultVariants: { surface: 'plain', padding: 'none' }
});
export type BoxVariants = VariantProps<typeof boxVariants>;src/lib/components/layout/box/box.module.css
@layer primitive {
.root {
min-width: 0;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius-3);
background: var(--surface-panel);
}
.raised {
border: 1px solid var(--line);
border-radius: var(--radius-3);
background: var(--surface-raised);
box-shadow: var(--shadow);
}
.sm {
padding: var(--space-4);
}
.md {
padding: var(--space-6);
}
.lg {
padding: var(--space-8);
}
}Separator
orientation · decorative — announced by default
Announced unless you say otherwise. Pass decorative for a rule a
reader already gets from the headings around it, which is most of them. The vertical links above
are decorative; the horizontal rule is announced.
No margin. Space around a separator comes from the parent's gap, like any other child.
Source src/lib/components/layout/separator/doc.ts · src/lib/components/layout/separator/Separator.svelte · src/lib/components/layout/separator/separator.module.css
src/lib/components/layout/separator/doc.ts
/**
* Separator — a line between groups of content.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* orientation? "horizontal" | "vertical" default "horizontal"
* decorative? boolean default false
* …every attribute of a div except role and children, and a ref
*
* # Behaviour
*
* R1 A 1px line in `--line`. Horizontal spans its parent's width; vertical
* spans the height of a flex row, and one line of text elsewhere.
* R2 By default it is announced as a separator. A vertical one also announces
* its orientation; a horizontal one does not need to, because horizontal
* is what a separator is assumed to be.
* R3 With `decorative` it is hidden from assistive technology and carries no
* orientation.
* R4 It adds no margin. Space around it comes from the parent's gap, like any
* other child of a Stack or Grid.
*
* # Deliberately absent
*
* Labels ("— or —"), thickness, and colour variants. A labelled divider is a
* composition; a heavier rule is a different element with a different meaning.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* Announced by default, as Radix and bits-ui do: over-announcing is noise,
* under-announcing is lost structure. Pass `decorative` for a rule a reader
* already gets from the headings around it, which is most of them.
*
* Hand-rolled rather than wrapped: the whole behaviour is a role and one
* attribute, and the headless primitives render the same div. It is a div
* rather than <hr> because a vertical <hr> is not a thing the platform styles
* or announces sensibly.
*/
export {};src/lib/components/layout/separator/Separator.svelte
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './separator.module.css';
export type SeparatorProps = Omit<
HTMLAttributes<HTMLDivElement>,
'class' | 'role' | 'children'
> & {
class?: string;
orientation?: 'horizontal' | 'vertical';
/** Visual grouping only: hidden from assistive technology. */
decorative?: boolean;
};
let {
orientation = 'horizontal',
decorative = false,
class: className = '',
...rest
}: SeparatorProps = $props();
</script>
<!-- Horizontal is the separator role's implicit orientation. -->
<div
{...rest}
role={decorative ? 'none' : 'separator'}
aria-orientation={!decorative && orientation === 'vertical' ? 'vertical' : undefined}
data-orientation={orientation}
class={cn(styles.root, styles[orientation], className)}
></div>src/lib/components/layout/separator/separator.module.css
@layer primitive {
.root {
flex: none;
border: 0;
background: var(--line);
}
.horizontal {
width: 100%;
height: 1px;
}
.vertical {
width: 1px;
/* Full height of a flex row; a line of text tall anywhere else. */
align-self: stretch;
min-height: 1em;
}
}AspectRatio
ratio — height follows width
The ratio is binding. Content larger than the box is cropped rather than stretching it. Media children fill the box and are cropped to it, keeping their own proportions.
No appearance. The rounded corners here are a class on the AspectRatio, not part of it.
Source src/lib/components/layout/aspect-ratio/doc.ts · src/lib/components/layout/aspect-ratio/AspectRatio.svelte · src/lib/components/layout/aspect-ratio/aspect-ratio.module.css
src/lib/components/layout/aspect-ratio/doc.ts
/**
* AspectRatio — a box whose height follows its width.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* ratio? number, width ÷ height default 1
* …every attribute of a div, and a ref
*
* # Behaviour
*
* R1 Renders one div as wide as its parent, with height = width ÷ ratio.
* R2 The ratio is binding: content larger than the box is cropped, never
* allowed to stretch it.
* R3 An image, video, iframe, canvas, or svg child fills the box and is
* cropped to it, keeping its own proportions (it covers, it does not
* distort). Any other child fills the box.
* R4 A ratio that is not a positive finite number is treated as 1.
* R5 No appearance: no radius, border, or background. A rounded thumbnail is
* the caller's class on this element.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The platform `aspect-ratio` property on a single element, rather than the
* headless libraries' padding-bottom wrapper, which renders two divs. R2 needs
* `overflow: hidden`: with overflow visible, a box's automatic minimum height
* lets tall content override the ratio. The box is a one-cell grid so any
* child stretches to fill both axes (R3) without absolute positioning.
*/
export {};src/lib/components/layout/aspect-ratio/AspectRatio.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './aspect-ratio.module.css';
export type AspectRatioProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
class?: string;
children?: Snippet;
/** Width divided by height: 16 / 9, 4 / 3, 1. */
ratio?: number;
};
let { children, ratio = 1, class: className = '', style, ...rest }: AspectRatioProps = $props();
const safe = $derived(Number.isFinite(ratio) && ratio > 0 ? ratio : 1);
</script>
<div
{...rest}
class={cn(styles.root, className)}
style={[`aspect-ratio: ${safe}`, style].filter(Boolean).join('; ')}
>
{@render children?.()}
</div>src/lib/components/layout/aspect-ratio/aspect-ratio.module.css
@layer primitive {
.root {
display: grid;
width: 100%;
min-width: 0;
/* Also what makes the ratio binding: with overflow visible, content
taller than the ratio would stretch the box instead of being cropped. */
overflow: hidden;
}
.root > * {
min-width: 0;
min-height: 0;
}
.root > :where(img, video, iframe, canvas, svg) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}