Skip to content
kerfjsGitHub

Drawer

A panel that slides in from the edge, for details and side tasks.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
import { state } from '@kerf-js/core'import {  Badge,  Button,  Drawer,  Row,  Stack,  Text} from '@kerf-js/veneer'export function DeployDetails() {  const open = state(false)  const picked = state(DEPLOYS[0]!)  const deploys = DEPLOYS.slice(0, 4)  const show = (d: (typeof DEPLOYS)[number]) => {    picked.value = d    open.value = true  }  return (    <Stack      gap="related"      divided    >      {deploys.map((d) => (        <Row          key={d.id}          justify="between"          gap="group"        >          <Stack gap="hair">            <Text>{d.project}</Text>            <Text              role="caption"              subtle            >              {d.message}            </Text>          </Stack>          <Button            size="sm"            emphasis="ghost"            tone="neutral"            onClick={() => show(d)}          >            Details          </Button>        </Row>      ))}      <Drawer        open={open}        title={picked.value.message}        description={          `${picked.value.project}, ` + picked.value.id        }      >        <Stack gap="related">          <Row justify="between">            <Text subtle>Status</Text>            <Badge              tone={                TONE_OF[picked.value.status as Status]              }              dot            >              {picked.value.status}            </Badge>          </Row>          <Row justify="between">            <Text subtle>Author</Text>            <Text>{picked.value.author}</Text>          </Row>          <Row justify="between">            <Text subtle>Branch</Text>            <Text>{picked.value.branch}</Text>          </Row>          <Row justify="between">            <Text subtle>Commit</Text>            <Text role="code">{picked.value.commit}</Text>          </Row>          <Row justify="between">            <Text subtle>Took</Text>            <Text>{picked.value.minutes} min</Text>          </Row>        </Stack>      </Drawer>    </Stack>  )}type Status =  | 'Live'  | 'Building'  | 'Failed'  | 'Paused'const TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}const DEPLOYS = [  {    id: 'd-4821',    project: 'storefront',    branch: 'main',    commit: '9f3ac21',    message: 'Cache product images at the edge',    author: 'Priya Raman',    status: 'Live',    minutes: 2,    at: '2026-10-31T16:42',  },  {    id: 'd-4820',    project: 'search-api',    branch: 'ranking-v2',    commit: '1be07d4',    message: 'Rank exact matches first',    author: 'Lena Fischer',    status: 'Building',    minutes: 6,    at: '2026-10-31T16:10',  },  {    id: 'd-4819',    project: 'mobile-bff',    branch: 'main',    commit: 'c44e190',    message: 'Retry timed out upstream calls',    author: 'Sam Okafor',    status: 'Failed',    minutes: 4,    at: '2026-10-31T15:27',  },  {    id: 'd-4818',    project: 'checkout',    branch: 'main',    commit: '70d2fa8',    message: 'Add Apple Pay to the last step',    author: 'Tomás Oliveira',    status: 'Live',    minutes: 3,    at: '2026-10-31T14:03',  },  {    id: 'd-4817',    project: 'edge-cache',    branch: 'main',    commit: 'e81b6c2',    message: 'Lower the stale window to 30s',    author: 'Jun Tanaka',    status: 'Live',    minutes: 1,    at: '2026-10-31T11:55',  },  {    id: 'd-4816',    project: 'billing',    branch: 'main',    commit: '2ad9e03',    message: 'Prorate seats on plan change',    author: 'Mara Lindqvist',    status: 'Live',    minutes: 3,    at: '2026-10-29T17:20',  },]const min = 60_000

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
Side
Codeupdates as you change the controls
import { Drawer } from '@kerf-js/veneer'<Drawer open={open} side="right" title="Deploy d-4821" description="storefront, on main">  ...</Drawer>

Usage

tsx
import { Drawer } from '@kerf-js/veneer'

Placement

Use side to choose the edge. The default, auto, is the right side, and the bottom on phones.

Right

tsx
import { state } from '@kerf-js/core'import { Button, Drawer, Text } from '@kerf-js/veneer'export function Right() {  const open = state(false)  return (    <>      <Button        emphasis="outline"        tone="neutral"        onClick={() => (open.value = true)}      >        Right      </Button>      <Drawer        open={open}        side="right"        title="Deploy d-4821"      >        <Text subtle>          For details of what you pressed.        </Text>      </Drawer>    </>  )}

Left

tsx
import { state } from '@kerf-js/core'import { Button, Drawer, Text } from '@kerf-js/veneer'export function Left() {  const open = state(false)  return (    <>      <Button        emphasis="outline"        tone="neutral"        onClick={() => (open.value = true)}      >        Left      </Button>      <Drawer        open={open}        side="left"        title="Projects"      >        <Text subtle>For navigation.</Text>      </Drawer>    </>  )}

Top

tsx
import { state } from '@kerf-js/core'import { Button, Drawer, Text } from '@kerf-js/veneer'export function Top() {  const open = state(false)  return (    <>      <Button        emphasis="outline"        tone="neutral"        onClick={() => (open.value = true)}      >        Top      </Button>      <Drawer        open={open}        side="top"        title="Search"      >        <Text subtle>          For a search or a short notice.        </Text>      </Drawer>    </>  )}

Bottom

tsx
import { state } from '@kerf-js/core'import { Button, Drawer, Text } from '@kerf-js/veneer'export function Bottom() {  const open = state(false)  return (    <>      <Button        emphasis="outline"        tone="neutral"        onClick={() => (open.value = true)}      >        Bottom      </Button>      <Drawer        open={open}        side="bottom"        title="Share"      >        <Text subtle>          For a quick task, and on phones.        </Text>      </Drawer>    </>  )}

Filters

A drawer holds a longer set of controls beside the page, with footer for its buttons.

tsx
import { derived, state } from '@kerf-js/core'import {  Button,  Checkbox,  Drawer,  Row,  Stack,  Switch,  Text} from '@kerf-js/veneer'export function Filters() {  const open = state(false)  const failed = state(false)  const regions = state(['eu-west'])  const flip = (r: string) =>    (regions.value = regions.value.includes(r)      ? regions.value.filter((x) => x !== r)      : [...regions.value, r])  const count = derived(    () => regions.value.length + (failed.value ? 1 : 0),  )  const clear = () => {    regions.value = []    failed.value = false  }  return (    <>      <Button        emphasis="outline"        tone="neutral"        icon="filter"        onClick={() => (open.value = true)}      >        Filters ({count.value})      </Button>      <Drawer        open={open}        title="Filter deploys"        footer={          <Row            gap="related"            justify="between"          >            <Button              emphasis="ghost"              tone="neutral"              onClick={clear}            >              Clear            </Button>            <Button data-close="">Show results</Button>          </Row>        }      >        <Stack gap="section">          <Stack gap="related">            <Text role="label">Region</Text>            {FILTER_REGIONS.map((r) => (              <Checkbox                key={r}                checked={regions.value.includes(r)}                onChange={() => flip(r)}              >                {r}              </Checkbox>            ))}          </Stack>          <Switch            checked={failed.value}            onChange={() =>              (failed.value = !failed.value)            }          >            Failed deploys only          </Switch>        </Stack>      </Drawer>    </>  )}const FILTER_REGIONS = [  'eu-west',  'us-east',  'ap-south',]

When to use

Use it for

  • Details of something picked from a list
  • A longer form or a set of filters

Reach for something else

  • Modal, when it is a decision, not a task
  • Popover, when a few controls near the button will do

Accessibility

Esc
Closes and returns focus to the opener
Tab
Stays inside the drawer
  • It is a native <dialog>, so the page behind is inert.
  • Focus goes to autofocus, else the title, never the handle.

Theming

Restyle it app-wide from veneer() in your kerf config. Every key takes colour, spacing, size, radius, shadow, type and motion; its own keys are listed. A state nests inside: hover: { bg: 'ink' }. Text on a fill you set is contrast-checked at build. How theming works

KeyOwn keysStates
parts.close—hover, focus, press, active, open, disabled
parts.sheet—none
ts
veneer({  colors: { brand: '#e8590c', ink: '#1b1b1f' },  parts: {    close: { size: 28 },    sheet: { radius: 24 },  },})

API Reference

PropTypeDefaultDescription
openState<boolean>—A state holding whether it is open. The drawer writes false when people close it.
titleunknown—Names the drawer.
descriptionunknown—A line under the title.
footerunknown—Stays at the bottom. Anything with data-close closes the drawer.
side"left" | "right" | "auto" | "top" | "bottom"'auto'Which edge. auto is the right, or the bottom under 600px, decided when it opens.
atDetent'half'A bottom sheet's opening height when its content is taller than half the window: half (the page stays in view) or full.
peekbooleanfalseAt the half stop the page behind stays live: a reader can scroll it and tap in it while the sheet waits.
inlinebooleanfalseDraws it open in the page's flow at its side's shape: no top layer, scrim or focus trap. For docs and previews; open still shows and hides it.
childrenunknown—What it contains.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="sheet"] [data-part="handle"]

handleheadertitledescriptionclosebodyfooter

RelatedModalPopover