Guide

Animations & styling

Headless data-state hooks, Tailwind utilities, custom keyframes.

Animations & styling

The package ships zero visual styles and zero animations. You drive everything via data-state="open" | "closed" attributes on the rendered elements.

The package waits for your CSS animations/transitions to finish before unmounting — close animations always play to completion.

Data attributes you can target

SelectorWhere it livesNotes
[data-modal-region]<ModalTarget> rootNo data-state — layout anchor
[data-modal-overlay][data-state]<ModalOverlay>data-state="open" while group has any active modal
[data-modal-root]<ModalRoot>No data-state — layout anchor
[data-modal-content][data-state]<ModalContent>data-state="open" while this is the visible top
[data-modal-content][data-instant]Lifetime of instantEnter modalBundled rule scoped to [data-state="open"] suppresses the enter animation only
[data-modal-overlay][data-instant]While a instantEnter modal is on topSame rule applied to <ModalOverlay> so overlay enter animation skips in sync
[data-modal-title], [data-modal-description]Title / description elementsSemantic anchors (no data-state)

Positioning rule (read this first)

<ModalRoot> already covers the whole target — its bundled CSS is position: absolute; inset: 0. It is the positioning context for the card.

  • Put layout utilities (flex, grid, padding, alignment) on <ModalRoot>.
  • Style <ModalContent> as a normal block: width, padding, background, border, shadow, animations.
  • Do not put position: fixed, position: absolute, inset-0, top-*, left-*, or -translate-* on <ModalContent>. You will fight the parent and break stacking.
<!-- correct -->
<ModalRoot class="flex items-center justify-center p-4">
  <ModalContent class="w-full max-w-md bg-white rounded-lg p-6 shadow-xl">
    <slot />
  </ModalContent>
</ModalRoot>

Strategy 1 — Tailwind + tw-animate-css (shadcn-style)

Pass utility classes directly to <ModalOverlay> and <ModalContent>:

<ModalTarget group="default">
  <ModalOverlay class="bg-black/80
    data-[state=open]:animate-in   data-[state=closed]:animate-out
    data-[state=open]:fade-in-0    data-[state=closed]:fade-out-0" />
</ModalTarget>
MyDialog.vue
<ModalRoot class="flex items-center justify-center">
  <ModalContent class="bg-white rounded-lg p-6 shadow-xl
    data-[state=open]:animate-in   data-[state=closed]:animate-out
    data-[state=open]:fade-in-0    data-[state=closed]:fade-out-0
    data-[state=open]:zoom-in-95   data-[state=closed]:zoom-out-95">
    <slot />
  </ModalContent>
</ModalRoot>

Requires the tw-animate-css plugin (or older tailwindcss-animate) for the animate-in/animate-out/fade-in-N/zoom-in-N utilities.

Strategy 2 — Custom CSS keyframes

<ModalRoot>
  <ModalContent class="my-card">
    <slot />
  </ModalContent>
</ModalRoot>

<style>
.my-card {
  background: white;
  border-radius: 12px;
  padding: 24px;
  /* starting values for enter */
  opacity: 0;
  transform: translateY(8px) scale(0.98);
}
.my-card[data-state='open'] {
  animation: card-in 200ms ease forwards;
}
.my-card[data-state='closed'] {
  animation: card-out 150ms ease forwards;
}

@keyframes card-in {
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}
@keyframes card-out {
  to {
    opacity: 0;
    transform: translateY(8px) scale(0.98);
  }
}
</style>

Strategy 3 — No animation

Skip animation entirely:

<ModalOverlay class="bg-black/50" />
<!-- no transition, no @keyframes — overlay just appears/disappears -->

With no animation to wait for, the modal unmounts immediately on close.

CSS-vars on <ModalTarget> (optional convenience)

For static visuals on overlays without writing class rules, you can drive a few values via custom properties on the region — they cascade to descendant [data-modal-overlay]:

<ModalTarget
  :style="{
    '--modal-overlay-bg': 'rgba(0,0,0,0.5)',
    '--modal-overlay-backdrop': 'blur(2px)'
  }"
  group="default"
>
  <ModalOverlay />
</ModalTarget>

These are user-defined custom properties — the package gives no defaults; you wire them in your own CSS:

[data-modal-overlay] {
  background: var(--modal-overlay-bg, transparent);
  backdrop-filter: var(--modal-overlay-backdrop, none);
}

Centering the card

<ModalRoot> ships only position: absolute; inset: 0; pointer-events: auto. Layout primitives are yours to choose — flex, grid, padding, anything.

<ModalRoot class="grid place-items-center">
  <ModalContent class="my-card">
    <slot />
  </ModalContent>
</ModalRoot>

Or via CSS targeting [data-modal-root]:

[data-modal-root] {
  display: flex;
  align-items: center;
  justify-content: center;
}

Top-anchored sheet (e.g. command palette):

<ModalRoot class="flex items-start justify-center pt-[15vh]">
  <ModalContent class="w-full max-w-xl ...">
    <slot />
  </ModalContent>
</ModalRoot>

prefers-reduced-motion

Respect the user OS setting in your CSS:

@media (prefers-reduced-motion: reduce) {
  [data-modal-overlay][data-state],
  [data-modal-content][data-state] {
    animation: none;
    transition: none;
  }
}

Skipping animation programmatically

When opening / closing programmatically you can skip enter / exit animations:

openModal(MyDialog, { instantEnter: true })       // no enter animation
handle.close({ instantExit: true })                // no exit animation
replaceModal(NextStep, { instantEnter: true })    // wizard step swap

instantEnter sets data-instant="" on both <ModalContent> and the topmost <ModalOverlay> for the entire lifetime of that modal. The bundled CSS rule

:where([data-modal-content][data-instant][data-state='open']),
:where([data-modal-overlay][data-instant][data-state='open']) {
  animation: none !important;
  transition: none !important;
}

suppresses any user enter animation on both elements. Suppression is scoped to [data-state="open"], so when the modal closes (data-state="closed") your exit animation still runs.

Copyright © 2026