Skip to content
kerfjsGitHub

Split

A fixed side next to a fluid main area, stacked when the container is narrow.

SourcePackage @kerf-js/veneerTry it

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 const

Installation

npm install @kerf-js/veneer

Try 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_000

When to use

Use it for

  • Settings with a list of sections
  • A list next to its detail
  • A page with a sidebar inside the content

Reach for something else

  • Grid, when the columns are equal
  • Shell, when this is the whole app frame

Accessibility

  • Stacked, the children keep source order, so put what people need first first. side changes which one is fixed, not the order.

API Reference

PropTypeDefaultDescription
childrenunknown—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.
atStep—When it becomes two columns: tablet at 600px of container, desktop at 1000px.

RelatedGridShellStack