Skip to content
kerfjsGitHub

Modal

A dialog that stops the page until it is answered.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
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/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
Placement
Size
Tone
Codeupdates as you change the controls
import { Modal } from '@kerf-js/veneer'<Modal open={open} title="Rename project" footer={<Button data-close="">Done</Button>}>  ...</Modal>

Usage

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

Placement

Use placement to choose where the modal opens.

Center

tsx
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

tsx
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

tsx
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

tsx
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

tsx
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

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

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

Invite a teammate

Use open to show the modal, footer for its buttons, and a form in the body.

tsx
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

Use it for

  • A decision people must make before going on
  • A short task that needs their full attention

Reach for something else

  • Drawer, when people want to see the page while they work
  • Popover, when it is a small panel the page can carry on without
  • Toaster, when it only tells people something happened

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

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

API Reference

PropTypeDefaultDescription
openState<boolean>—A state holding whether it is open. The modal writes false when people close it.
titleunknown—Names the dialog.
descriptionunknown—A line under the title, read out on open.
footerunknown—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.
alertbooleanfalseFor a question that must be answered: no closing from a press outside.
toneTone—Colours the header. critical for destructive questions.
markunknown—An icon or result mark drawn above the title, such as a success check.
inlinebooleanfalseDraws 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.
childrenunknown—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

RelatedDrawerPopoverButton