Components · Overlays & menus
Modal
A focused surface for a decision or a short task, on a card with shadow-overlay elevation over a scrim. Build it on the native <dialog> element, so focus-trap, Escape, and the backdrop come for free.
Preview
Delete project?
This permanently removes the project and all of its data. This action can't be undone.
Build with the skill
No package to install — hand this to your AI to generate or migrate the component in your stack.
Apply the Zetta "Modal" component to this project using the zetta-design-md skill.
WHEN TO USE
- Migrating or fixing existing code (the common case): audit the current implementation against the `modal` spec (variants: modal, modal-overlay) and CORRECT every deviation — replace off-spec values, don't only add new ones.
- New component: build it to the `modal` spec in this project's stack.
AUDIT & FIX (when the component already exists)
Compare it to the skill's spec and fix, don't just append: hardcoded colors/hex, off-scale radius, wrong fonts, dark/navy fills, shadows used in place of borders (or missing borders), brand lime used as a CTA or interaction (must be primary indigo), and any missing or wrong hover / focus / active / disabled / invalid state or missing 2px focus ring. Replace each with the correct Zetta token.
ALWAYS
- Pull the authoritative spec and tokens from the skill — never guess or hardcode values.
- Use this project's own component conventions and framework idioms.
- Honor the Zetta guardrails (brand = identity only, primary = interaction, shadows for overlays) and verify in Light, Dark, and Accessibility. Anatomy & rules
╭──────────────────────────────────────╮
│ Poni ─ Title text [×] close │ ← modal-poni (draggable)
├──────────────────────────────────────┤
│ │
│ modal-body content │ ← scrollable
│ │
├──────────────────────────────────────┤
│ [Cancel] [Confirm] │ ← modal-footer
╰──────────────────────────────────────╯
Parts:
modal-overlay— fixed inset blanket behind the modalmodal— the dialog surface (centered by overlay flexbox)modal-poni— draggable header strip; contains title and close buttonmodal-title— heading text inside Ponimodal-close— ×-close icon button inside Poni (right-aligned)modal-body— scrollable content areamodal-footer— action buttons, right-aligned by default;modal-footer-leftfor tertiary actions
| State | Visual |
|---|---|
| Closed | Not rendered |
| Opening | Fade in + scale from 95% to 100%, {motion.duration-slow} |
| Open | Fully visible, focus trapped, blanket behind |
| Closing | Fade out + scale to 95%, {motion.duration-moderate} |
| Loading (content) | Skeleton fills modal body |
| Confirming (destructive) | Primary action applies button-danger state |
When to use
- Confirming a destructive action — delete, permanently remove, overwrite.
- Collecting input required before the user can continue — a required form step.
- Presenting a decision between two or more choices that affect the page state.
When not to use
- Non-blocking information — use Toast or Section Message.
- Secondary workflows that don't interrupt the primary context — use Drawer.
- Long multi-step flows — break into a dedicated page or a stepped Drawer.
- Warnings that don't require user action — use Banner or Alert.
Do
- Keep modal titles short — one noun phrase. "Delete project", "Confirm export".
- Lead with the primary action button — right-aligned, rightmost position. Secondary (cancel) to its left.
- Close the modal on successful action completion.
- Show a loading state on the confirm button while async operations run.
Don't
- Don't open a modal from within another modal.
- Don't use a modal for error states that can be shown inline.
- Don't put navigation inside a modal — modals are not page-substitutes.
- Don't let the modal body scroll in fullscreen mode — break content into sections instead.
- Title: noun phrase, not a sentence. "Delete workspace" not "Are you sure you want to delete this workspace?".
- Body: explain what will happen and what is irreversible. Keep under 3 sentences.
- Primary action label: verb that matches the title. "Delete", "Confirm", "Export", "Save changes".
- Cancel label: always "Cancel" — never "No", "Go back", or "Dismiss".
- Danger confirmation: for destructive actions, the primary button uses
button-dangerwith an explicit label ("Delete permanently").
Keyboard
| Key | Behavior |
|---|---|
Tab |
Cycle focus through interactive elements inside the modal (focus trap) |
Shift + Tab |
Reverse cycle within the focus trap |
Enter |
Activate the focused button |
Escape |
Close the modal (non-required-decision modals only) |
Accessibility
- ARIA role:
role="dialog"witharia-modal="true"on the modal surface. - Label:
aria-labelledbypointing to themodal-titleelement. - Body description:
aria-describedbypointing to the first paragraph ofmodal-bodywhen relevant. - Focus management: on open, focus moves to the first interactive element inside the modal (or the modal container if no interactive element). On close, focus returns to the trigger element.
- Focus trap: focus is trapped and cycles within the modal — no focus escape to the blanket or page behind.
- Screen reader: the dialog role causes most screen readers to announce the title immediately on open.
- Poni drag: drag interaction is pointer-device only. Keyboard users reposition via no mechanism — dragging is enhancement only, not required functionality.
Spec
Values and token references resolved straight from the Zetta spec.
modal
- backgroundColor
- {colors.surface-card}
- textColor
- {colors.ink}
- borderColor
- {colors.hairline}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- shadow
- {elevation.shadow.overlay}
- width
- 100%
- zIndex
- {elevation.z-index.modal}
- display
- flex
- flexDirection
- column
- maxHeight
- calc(100vh - 96px)
- overflow
- hidden
modal-sm
- maxWidth
- 360px
modal-md
- maxWidth
- 480px
modal-lg
- maxWidth
- 640px
modal-xl
- maxWidth
- 800px
modal-fullscreen
- maxWidth
- 100vw
- maxHeight
- 100vh
- width
- 100vw
- height
- 100vh
- borderRadius
- {rounded.none}
modal-overlay
- position
- fixed
- inset
- 0
- backgroundColor
- {colors.overlay}
- display
- flex
- alignItems
- center
- justifyContent
- center
- zIndex
- {elevation.z-index.blanket}
- padding
- {spacing.page}
modal-poni
- display
- flex
- alignItems
- center
- justifyContent
- space-between
- paddingX
- {spacing.page}
- paddingY
- {spacing.md}
- borderBottomColor
- {colors.hairline}
- borderBottomWidth
- 1px
- cursor
- grab
- userSelect
- none
- flexShrink
- 0
modal-poni-active
- cursor
- grabbing
modal-title
- fontFamily
- Inter
- fontSize
- 18px
- fontWeight
- 600
- lineHeight
- 1.35
- textColor
- {colors.ink}
modal-close
- display
- flex
- alignItems
- center
- justifyContent
- center
- width
- 32px
- height
- 32px
- borderRadius
- {rounded.base}
- iconSize
- {icons.sizeMap.md}
- iconColor
- {colors.muted}
- cursor
- pointer
- flexShrink
- 0
modal-close-hover
- backgroundColor
- {colors.primary-bg}
- iconColor
- {colors.primary}
modal-close-focus
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- 2px
modal-body
- paddingX
- {spacing.page}
- paddingY
- {spacing.md}
- overflowY
- auto
- flex
- 1
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 400
- lineHeight
- 1.60
- textColor
- {colors.body}
modal-footer
- display
- flex
- alignItems
- center
- justifyContent
- flex-end
- gap
- {spacing.sm}
- paddingX
- {spacing.page}
- paddingY
- {spacing.md}
- borderTopColor
- {colors.hairline}
- borderTopWidth
- 1px
- flexShrink
- 0
modal-footer-left
- marginRight
- auto
modal-stack-offset
- translateY
- -12px
- scale
- 0.96
- zIndex
- {elevation.z-index.modal}
modal-bottom-sheet
- position
- fixed
- bottom
- 0
- left
- 0
- right
- 0
- maxHeight
- 90vh
- borderRadius
- 12px 12px 0 0
- backgroundColor
- {colors.surface-card}
- shadow
- {elevation.shadow.overlay}
- zIndex
- {elevation.z-index.modal}
- overflowY
- auto
modal-bottom-sheet-handle
- width
- 32px
- height
- 4px
- borderRadius
- {rounded.full}
- backgroundColor
- {colors.border-strong}
- margin
- 12px auto 0
modal-enter
- animation
- modal-in {motion.duration-slow} {motion.easing-enter}
- opacity
- 0 → 1
- transform
- scale(0.95) → scale(1)
modal-exit
- animation
- modal-out {motion.duration-base} {motion.easing-exit}
- opacity
- 1 → 0
- transform
- scale(1) → scale(0.95)
modal-bottom-sheet-enter
- animation
- sheet-in {motion.duration-moderate} {motion.easing-enter}
- transform
- translateY(100%) → translateY(0)
modal-bottom-sheet-exit
- animation
- sheet-out {motion.duration-base} {motion.easing-exit}
- transform
- translateY(0) → translateY(100%)
Known gaps
- Fullscreen modal mobile behavior — currently uses
modal-fullscreen; swipe-down dismiss not yet spec'd for fullscreen. - Wizard modal step validation (blocking Next until required fields are filled) — runtime logic, not tokenized.