Animations & styling
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
| Selector | Where it lives | Notes |
|---|---|---|
[data-modal-region] | <ModalTarget> root | No 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 modal | Bundled rule scoped to [data-state="open"] suppresses the enter animation only |
[data-modal-overlay][data-instant] | While a instantEnter modal is on top | Same rule applied to <ModalOverlay> so overlay enter animation skips in sync |
[data-modal-title], [data-modal-description] | Title / description elements | Semantic 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>
<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.
