Modal
A dialog that stops the page until it is answered.
Preview
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Default() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > Open modal </Button> <Modal open={open} title="Rename project" footer={<Button data-close="">Done</Button>} > <Text subtle> Press Done, the cross or Escape to close. </Text> </Modal> </> )}Installation
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.
import { Modal } from '@kerf-js/veneer'<Modal open={open} title="Rename project" footer={<Button data-close="">Done</Button>}> ...</Modal>Usage
import { Modal } from '@kerf-js/veneer'Placement
Use placement to choose where the modal opens.
Center
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Center() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > Center </Button> <Modal open={open} placement="center" title="Rename project" footer={<Button data-close="">Done</Button>} > <Text subtle> The default: centred on the page. </Text> </Modal> </> )}Top
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Top() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > Top </Button> <Modal open={open} placement="top" title="Search the workspace" footer={<Button data-close="">Done</Button>} > <Text subtle> Sits near the top, for search and quick commands. </Text> </Modal> </> )}Bottom
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Bottom() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > Bottom </Button> <Modal open={open} placement="bottom" title="Share this deploy" footer={<Button data-close="">Done</Button>} > <Text subtle>Rises from the bottom edge.</Text> </Modal> </> )}Left
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Left() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > Left </Button> <Modal open={open} placement="left" title="Project menu" footer={<Button data-close="">Done</Button>} > <Text subtle> Fills the left side, for navigation. </Text> </Modal> </> )}Right
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Right() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > Right </Button> <Modal open={open} placement="right" title="Deploy details" footer={<Button data-close="">Done</Button>} > <Text subtle> Fills the right side, for details. </Text> </Modal> </> )}Full
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Full() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > Full </Button> <Modal open={open} placement="full" title="Compare two deploys" footer={<Button data-close="">Done</Button>} > <Text subtle> Covers the whole window, for wide work. </Text> </Modal> </> )}Sizes
Use size for how wide the modal is: sm, md or lg.
import { state } from '@kerf-js/core'import { Button, Modal, Text } from '@kerf-js/veneer'export function Sizes() { const small = state(false) const medium = state(false) const large = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (small.value = true)} > Small </Button> <Button emphasis="outline" tone="neutral" onClick={() => (medium.value = true)} > Medium </Button> <Button emphasis="outline" tone="neutral" onClick={() => (large.value = true)} > Large </Button> <Modal open={small} size="sm" title="Small" > <Text subtle>A question or one field.</Text> </Modal> <Modal open={medium} title="Medium" > <Text subtle> A short form. This is the default. </Text> </Modal> <Modal open={large} size="lg" title="Large" > <Text subtle> A table, a diff or anything wide. </Text> </Modal> </> )}With form
Give a field autofocus and focus lands there on open.
import { state } from '@kerf-js/core'import { Button, Input, Modal, Stack, Text} from '@kerf-js/veneer'export function Rename() { const open = state(false) const name = state('storefront') const save = (e: Event) => { e.preventDefault() open.value = false } return ( <Stack gap="related" align="center" > <Text role="heading">{name.value}</Text> <Button emphasis="outline" tone="neutral" icon="pencil" onClick={() => (open.value = true)} > Rename </Button> <Modal open={open} size="sm" title="Rename project" footer={ <> <Button emphasis="ghost" tone="neutral" data-close="" > Cancel </Button> <Button onClick={save}>Save</Button> </> } > <Input label="Project name" autofocus value={name.value} onInput={(e: Event) => (name.value = ( e.target as HTMLInputElement ).value) } /> </Modal> </Stack> )}With a mark
mark draws above the title.
import { state } from '@kerf-js/core'import { Button, Icon, Modal } from '@kerf-js/veneer'export function Result() { const open = state(false) return ( <> <Button icon="rocket" onClick={() => (open.value = true)} > Deploy </Button> <Modal open={open} size="sm" mark={ <Icon name="check-circle" size={32} /> } title="storefront is live" description={ 'Deployed 9f3ac21 to eu-west in 2 minutes.' } footer={<Button data-close="">Nice</Button>} /> </> )}Alert
With alert, a press outside does not close it. Put autofocus on the safe button.
import { state } from '@kerf-js/core'import { Button, Modal, Stack, Text} from '@kerf-js/veneer'export function Confirm() { const open = state(false) const kept = state(true) return ( <Stack gap="related" align="center" > <Text subtle> {kept.value ? 'storefront is still here.' : 'storefront is gone.'} </Text> <Button tone="critical" onClick={() => (open.value = true)} > Delete project </Button> <Modal open={open} alert size="sm" tone="critical" title="Delete storefront?" description={ 'Its 412 deploys and all its' + " logs go too. This can't be undone." } footer={ <> <Button emphasis="ghost" tone="neutral" autofocus data-close="" > Keep it </Button> <Button tone="critical" data-close="" onClick={() => (kept.value = false)} > Delete project </Button> </> } /> </Stack> )}Scrollable content
The body scrolls on its own, and the header and footer stay put.
import { state } from '@kerf-js/core'import { Button, Modal, Stack, Text} from '@kerf-js/veneer'export function LongBody() { const open = state(false) return ( <> <Button emphasis="outline" tone="neutral" onClick={() => (open.value = true)} > See all deploys </Button> <Modal open={open} title="Deploys today" description={ 'The header and footer stay' + ' put while the middle scrolls.' } footer={<Button data-close="">Close</Button>} > <Stack gap="group"> {[...DEPLOYS, ...DEPLOYS].map((d, i) => ( <Stack key={i} gap="hair" > <Text>{d.message}</Text> <Text role="caption" subtle > {d.author}, {d.project}, {d.minutes} min </Text> </Stack> ))} </Stack> </Modal> </> )}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_000Invite a teammate
Use open to show the modal, footer for its buttons, and a form in the body.
import { state } from '@kerf-js/core'import { Button, Input, Modal, Select, Stack, Text} from '@kerf-js/veneer'export function Invite() { const open = state(false) const email = state('') const role = state('Member') const sent = state('') const send = () => { const invitation = `Invited ${email.value} as ` sent.value = invitation + `${role.value}.` email.value = '' open.value = false } return ( <Stack gap="related" align="center" > <Button icon="user" onClick={() => (open.value = true)} > Invite a teammate </Button> <Text subtle>{sent.value}</Text> <Modal open={open} size="sm" title="Invite a teammate" description={ 'They get an email' + ' with a link to join kerf.' } footer={ <> <Button emphasis="ghost" tone="neutral" data-close="" > Cancel </Button> <Button disabled={!email.value.includes('@')} onClick={send} > Send invite </Button> </> } > <Stack gap="group"> <Input label="Work email" type="email" placeholder="noor@kerf.dev" autofocus value={email.value} onInput={(e: Event) => (email.value = ( e.target as HTMLInputElement ).value) } /> <Select label="Role" value={role} options={ROLES} /> </Stack> </Modal> </Stack> )}const ROLES = [ { value: 'Admin', label: 'Admin' }, { value: 'Member', label: 'Member' }, { value: 'Viewer', label: 'Viewer' },]When to use
Accessibility
- Esc
- Closes and returns focus to the opener
- Tab
- Stays inside the dialog
- It is a native
<dialog>, so everything behind it is inert. - The title names it and the description describes it.
- Focus goes to
autofocus, else the title, and back to the opener on every way of closing.
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
| Key | Own keys | States |
|---|---|---|
parts.close | — | hover, focus, press, active, open, disabled |
parts.dialog | — | none |
veneer({ colors: { brand: '#e8590c', ink: '#1b1b1f' }, parts: { close: { size: 28 }, dialog: { radius: 24, padding: 'group' }, },})API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
open | State<boolean> | — | A state holding whether it is open. The modal writes false when people close it. |
title | unknown | — | Names the dialog. |
description | unknown | — | A line under the title, read out on open. |
footer | unknown | — | The buttons. Anything with data-close closes the modal. |
size | "sm" | "md" | "lg" | — | sm, md or lg. |
placement | "center" | "left" | "right" | "top" | "bottom" | "full" | — | Where it opens: center, top, bottom, left, right or full. |
alert | boolean | false | For a question that must be answered: no closing from a press outside. |
tone | Tone | — | Colours the header. critical for destructive questions. |
mark | unknown | — | An icon or result mark drawn above the title, such as a success check. |
inline | boolean | false | Draws it open in the page's flow instead of over it: no top layer, scrim or focus trap. For docs and previews; open still shows and hides it. |
children | unknown | — | What it contains. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="dialog"] [data-part="header"]
headertitledescriptionclosebodyfooter