Zetta v1.6

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.

Prompt for your AI
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 modal
  • modal — the dialog surface (centered by overlay flexbox)
  • modal-poni — draggable header strip; contains title and close button
  • modal-title — heading text inside Poni
  • modal-close — ×-close icon button inside Poni (right-aligned)
  • modal-body — scrollable content area
  • modal-footer — action buttons, right-aligned by default; modal-footer-left for 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-danger with 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" with aria-modal="true" on the modal surface.
  • Label: aria-labelledby pointing to the modal-title element.
  • Body description: aria-describedby pointing to the first paragraph of modal-body when 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.