Skip to content
weftGitHub

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/warp

Usage

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_000

Invite 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

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

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

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