Skip to content

Modal

A modal with backdrop, container, header, body, and footer, built with the slots shape of defineComponent. The parts are tightly coupled inside a single container, so slots give them a shared namespace instead of forcing consumers to remember five separate prefixed classes (the way Dropdown's siblings do).

Authoring

ts
// recipes/modal.config.ts
import { defineComponent } from 'varia'

export default defineComponent('modal', {
  slots: {
    root: 'fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4',
    container:
      'relative w-full rounded-lg bg-white shadow-xl ring-1 ring-gray-200 max-h-[90vh] overflow-hidden flex flex-col',
    header: 'flex items-start justify-between gap-4 p-4 border-b border-gray-200',
    title: 'text-lg font-semibold text-gray-900',
    description: 'mt-1 text-sm text-gray-600',
    body: 'p-4 overflow-y-auto flex-1',
    footer: 'flex items-center justify-end gap-2 p-4 border-t border-gray-200',
    close:
      'absolute top-3 right-3 inline-flex items-center justify-center rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
  },
  variants: {
    size: {
      sm: { container: 'max-w-sm' },
      md: { container: 'max-w-md' },
      lg: { container: 'max-w-lg' },
      xl: { container: 'max-w-2xl' },
    },
  },
})

Two notes on the config:

  1. The root slot maps to the bare component class (modal); every other slot maps to modal__slotName using BEM. The double underscore separates slot classes from variant classes, which use single dashes.
  2. The size variant is slot-keyed: each size value targets the container slot. modal-size-md doesn't change the backdrop or header; it only sets the inner box's max-width. Slot-keyed variants emit as descendant-selector CSS rules (.modal-size-md .modal__container { max-width: ... }), so they apply through the tree without consumers adding a class to the container element.

Live preview

A modal rendered statically (open, inline, contained inside the docs page rather than full-viewport) so you can see all the slots at once:

Size variants in action

The interesting property of size being a slot-keyed variant is that swapping it changes only the container's max-width. The backdrop, header padding, and close-button position don't react. Here are the same modal contents at the smallest and largest sizes; md and lg sit between them:

modal-size-sm
modal-size-xl

The CSS rule each variant emits, for reference:

css
.modal-size-sm .modal__container { max-width: var(--container-sm); }
.modal-size-md .modal__container { max-width: var(--container-md); }
.modal-size-lg .modal__container { max-width: var(--container-lg); }
.modal-size-xl .modal__container { max-width: var(--container-2xl); }

The variant class lives on the root, but the styling lands on the container via the descendant selector. No class change is needed on modal__container itself; varia writes the descendant rule into a preflight at preset construction.

Consumption

html
<div class="modal modal-size-md" role="dialog" aria-modal="true" aria-labelledby="m-title">
  <div class="modal__container">
    <div class="modal__header">
      <div>
        <h2 class="modal__title" id="m-title">Confirm deletion</h2>
        <p class="modal__description">This action can't be undone.</p>
      </div>
      <button class="modal__close" type="button" aria-label="Close">×</button>
    </div>

    <div class="modal__body">
      ...body content...
    </div>

    <div class="modal__footer">
      <button class="btn btn-c-neutral btn-style-outline btn-s-sm" type="button">Cancel</button>
      <button class="btn btn-c-danger btn-style-solid btn-s-sm" type="button">Delete</button>
    </div>
  </div>
</div>

Behavior is the consumer's problem: varia doesn't ship open/close logic, focus trapping, or scroll locking. Pair these classes with your framework's dialog primitive (native <dialog>, Radix Dialog, Headless UI). varia doesn't apply default variants, so the container has no max-width unless a size class is present.

Generated class names

ClassSlot
modalThe root — full-viewport backdrop with centering
modal__containerThe dialog box
modal__headerTop bar (title row)
modal__titleHeading inside the header
modal__descriptionOptional subtitle under the title
modal__bodyScrollable middle section
modal__footerAction row (right-aligned by default)
modal__closeFloating close button
modal-size-sm / -md / -lg / -xlContainer max-width

Nine consumer-facing classes, one component, slot-keyed sizing.

Why a <dialog>-shaped helper isn't part of varia

varia emits classes; it doesn't render DOM. The native <dialog> element handles focus trapping and the top-layer paint for free in modern browsers, but you opt into it from your framework. A typical pairing:

html
<dialog class="modal modal-size-md" role="dialog">
  <div class="modal__container">...</div>
</dialog>

The .modal's position: fixed and inset: 0 give you the backdrop styling even outside the top layer, so the same classes work whether you reach for <dialog>, a portaled <div>, or your framework's dialog component.

Released under the MIT License.