Modal
A dialog that stops the page until it is answered.
SourcePackage @weft-js/warpPlayground
Preview
tsx
import { state } from '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 @weft-js/warpUsage
tsx
import { Modal } from '@weft-js/warp'Placement
Use placement to choose where the modal opens.
Center
tsx
import { state } from '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Input, Modal, Stack, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Icon, Modal } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Stack, Text } from '@weft-js/warp'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 '@weft-js/core'import { Button, Modal, Stack, Text } from '@weft-js/warp'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.
tsx
import { state } from '@weft-js/core'import { Button, Input, Modal, Select, Stack, Text } from '@weft-js/warp'import type { Invite } from '@weft-js/warp'export function ModalInvite() { const open = state(false) const email = state('') const role = state('Member') const sent = state('') const send = () => { sent.value = `Invited ${email.value} as ${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 weft." 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@weft.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
Playground
Placement
Size
Tone
import { Modal } from '@weft-js/warp'<Modal open={open} title="Rename project" footer={<Button data-close="">Done</Button>}> ...</Modal>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.
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