Split
A fixed side next to a fluid main area, stacked when the container is narrow.
Preview
tsx
import { Split, Stack } from '@kerf-js/veneer'export function Widths() { return ( <Stack gap="related"> {WIDTHS.map((width) => ( <Split key={width} width={width} gap="related" > <Box>{width}</Box> <Box>Main</Box> </Split> ))} </Stack> )}type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const min = 60_000const BOX = 'display:grid;align-content:center;' + 'gap:2px;' + 'padding:var(--veneer-space-related);' + 'border:var(--veneer-border-hairline) ' + 'solid var(--veneer-color-border);' + 'border-radius:' + 'var(--veneer-radius-surface);' + 'background:var(--veneer-color-surface);' + 'color:var(--veneer-color-text);' + 'font-size:var(--veneer-text-caption-size);' + 'min-inline-size:0'const projectOf = (c: unknown) => { const n = Number(typeof c === 'function' ? c() : c) return Number.isInteger(n) && n > 0 ? PROJECTS[(n - 1) % PROJECTS.length] : undefined}function Box({ tall, children, ...rest}: { tall?: number children?: unknown [attr: string]: unknown}) { return ( <div style={ tall ? `${BOX};min-block-size:${tall}px` : BOX } {...rest} > {projectOf(children) ? ( <> <strong>{projectOf(children)!.name}</strong> <span style={ 'color:var(--veneer-color-text-subtle)' } > {projectOf(children)!.region} </span> </> ) : ( children )} </div> )}const WIDTHS = [ 'rail', 'nav', 'list', 'half',] as constInstallation
npm install @kerf-js/veneerTry it
Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.
Live preview
Controls
Presets
Width
Side
At
Codeupdates as you change the controls
import { Split } from '@kerf-js/veneer'<Split> <Box>Side</Box> <Box>Main</Box></Split>Usage
tsx
import { Split } from '@kerf-js/veneer'Which side
side="end" fixes the second child, so the first one is the fluid one.
tsx
import { Split, Stack } from '@kerf-js/veneer'export function Sides() { return ( <Stack gap="related"> <Split width="nav" gap="related" > <Box tall={72}>Side, at the start</Box> <Box tall={72}>Main</Box> </Split> <Split width="nav" side="end" gap="related" > <Box tall={72}>Main</Box> <Box tall={72}>Side, at the end</Box> </Split> </Stack> )}type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const min = 60_000const BOX = 'display:grid;align-content:center;' + 'gap:2px;' + 'padding:var(--veneer-space-related);' + 'border:var(--veneer-border-hairline) ' + 'solid var(--veneer-color-border);' + 'border-radius:' + 'var(--veneer-radius-surface);' + 'background:var(--veneer-color-surface);' + 'color:var(--veneer-color-text);' + 'font-size:var(--veneer-text-caption-size);' + 'min-inline-size:0'const projectOf = (c: unknown) => { const n = Number(typeof c === 'function' ? c() : c) return Number.isInteger(n) && n > 0 ? PROJECTS[(n - 1) % PROJECTS.length] : undefined}function Box({ tall, children, ...rest}: { tall?: number children?: unknown [attr: string]: unknown}) { return ( <div style={ tall ? `${BOX};min-block-size:${tall}px` : BOX } {...rest} > {projectOf(children) ? ( <> <strong>{projectOf(children)!.name}</strong> <span style={ 'color:var(--veneer-color-text-subtle)' } > {projectOf(children)!.region} </span> </> ) : ( children )} </div> )}When it stacks
It answers to its container, not the screen. Set the stage's Width to Tablet: the desktop one stacks first.
tsx
import { Split, Stack, Text } from '@kerf-js/veneer'export function Steps() { return ( <Stack gap="group"> <Stack gap="tight"> <Text role="label" subtle > at="tablet": two columns from 600px </Text> <Split width="nav" gap="related" > <Box>Side</Box> <Box>Main</Box> </Split> </Stack> <Stack gap="tight"> <Text role="label" subtle > at="desktop": two columns from 1000px </Text> <Split width="nav" gap="related" at="desktop" > <Box>Side</Box> <Box>Main</Box> </Split> </Stack> </Stack> )}type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const min = 60_000const BOX = 'display:grid;align-content:center;' + 'gap:2px;' + 'padding:var(--veneer-space-related);' + 'border:var(--veneer-border-hairline) ' + 'solid var(--veneer-color-border);' + 'border-radius:' + 'var(--veneer-radius-surface);' + 'background:var(--veneer-color-surface);' + 'color:var(--veneer-color-text);' + 'font-size:var(--veneer-text-caption-size);' + 'min-inline-size:0'const projectOf = (c: unknown) => { const n = Number(typeof c === 'function' ? c() : c) return Number.isInteger(n) && n > 0 ? PROJECTS[(n - 1) % PROJECTS.length] : undefined}function Box({ tall, children, ...rest}: { tall?: number children?: unknown [attr: string]: unknown}) { return ( <div style={ tall ? `${BOX};min-block-size:${tall}px` : BOX } {...rest} > {projectOf(children) ? ( <> <strong>{projectOf(children)!.name}</strong> <span style={ 'color:var(--veneer-color-text-subtle)' } > {projectOf(children)!.region} </span> </> ) : ( children )} </div> )}A settings page
Pick a section. Each side is a container, so what's inside reflows by its own width.
tsx
import { state } from '@kerf-js/core'import { Button, Heading, Input, Nav, Row, Split, Stack, Text} from '@kerf-js/veneer'export function Settings() { const section = state('General') const pick = (e: Event, name: string) => { e.preventDefault() section.value = name } return ( <Split width="nav" gap="section" > <Nav label="Project settings" axis="y" > {SECTIONS.map((name) => ( <a key={name} href={`#${name}`} aria-current={ section.value === name ? 'page' : undefined } onClick={(e: Event) => pick(e, name)} > {name} </a> ))} </Nav> <Stack gap="group"> <Stack gap="hair"> <Heading level={3}>{section.value}</Heading> <Text subtle>Settings for storefront.</Text> </Stack> <Input label="Project name" defaultValue="storefront" /> <Input label="Production branch" defaultValue="main" /> <Row justify="end" gap="tight" > <Button emphasis="ghost" tone="neutral" > Cancel </Button> <Button>Save changes</Button> </Row> </Stack> </Split> )}const SECTIONS = [ 'General', 'Domains', 'Team', 'Billing',]List and detail
Use width to size the list column, and the detail takes the rest.
tsx
import { state } from '@kerf-js/core'import { Badge, Card, Grid, Heading, Row, Split, Stack, Text, Toggle} from '@kerf-js/veneer'export function MasterDetail() { const id = state('checkout') const shown = () => PROJECTS.find((p) => p.id === id.value)! return ( <Split width="nav" gap="group" > <Stack gap="hair"> {PROJECTS.slice(0, 6).map((p) => ( <Toggle key={p.id} size="sm" pressed={id.value === p.id} onClick={() => (id.value = p.id)} > {p.name} </Toggle> ))} </Stack> <Card> <Stack gap="related"> <Row justify="between" gap="tight" > <Heading level={3}>{shown().name}</Heading> <Badge tone={TONE_OF[shown().status]} dot > {shown().status} </Badge> </Row> <Text subtle> {shown().owner} looks after this one, in{' '} {shown().region}. </Text> <Grid min="xs" cols={3} gap="tight" > <Stack gap="hair"> <Text role="label" subtle > Deploys </Text> <Text>{shown().deploys}</Text> </Stack> <Stack gap="hair"> <Text role="label" subtle > Uptime </Text> <Text>{shown().uptime}%</Text> </Stack> <Stack gap="hair"> <Text role="label" subtle > Latency </Text> <Text>{shown().latency} ms</Text> </Stack> </Grid> </Stack> </Card> </Split> )}type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const TONE_OF: Record< Status, 'positive' | 'info' | 'critical' | 'neutral'> = { Live: 'positive', Building: 'info', Failed: 'critical', Paused: 'neutral',}const min = 60_000When to use
Accessibility
- Stacked, the children keep source order, so put what people need first first.
sidechanges which one is fixed, not the order.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
children | unknown | — | What it contains. |
as | "div" | "section" | "article" | "main" | "header" | "footer" | "nav" | "… | — | The element it renders as. |
gap | "section" | "none" | "hair" | "tight" | "related" | "group" | "page" | — | The space between the sides, from the space scale. |
side | "start" | "end" | — | Which child is fixed. start is the first, end the last. |
width | "nav" | "rail" | "list" | "half" | — | The fixed side: rail, nav, list or half. Default 300px. |
at | Step | — | When it becomes two columns: tablet at 600px of container, desktop at 1000px. |