@slithy/modal-css
v0.1.1
Published
Plain CSS transition adapter for @slithy/modal-kit.
Readme
@slithy/modal-css
Zero-dependency CSS transition adapter for @slithy/modal-kit. Animates enter/leave with plain CSS transitions instead of a JS animation library — no runtime dependency beyond @slithy/modal-kit and @slithy/utils.
Reach for @slithy/modal-spring instead if you need drag-to-dismiss or spring physics. Reach for this package when you want the animation to live entirely in CSS, or want to keep the dependency footprint minimal.
Installation
pnpm add @slithy/modal-core @slithy/modal-kit @slithy/modal-cssNo animation library peer dependency — only react (^18 || ^19).
Setup
import { LayerProvider, LayerStackPriority } from "@slithy/layers";
import { Portal } from "@slithy/portal";
import { ModalRenderer } from "@slithy/modal-css";
import "@slithy/modal-css/css";
export function App() {
const modalZ = LayerStackPriority.Modal;
return (
<LayerProvider id="app" zIndex={LayerStackPriority.App}>
<main>{/* your app */}</main>
<ModalRenderer
renderLayer={(children) => (
<LayerProvider id="modal" zIndex={modalZ}>
{children}
</LayerProvider>
)}
renderPortal={(children) => <Portal>{children}</Portal>}
zIndex={modalZ}
backdropZIndex={modalZ - 1}
/>
</LayerProvider>
);
}renderLayer and renderPortal are optional — omit them if you don't use @slithy/layers or @slithy/portal.
When @slithy/layers is not used, modals run in standalone mode and layerIsActive behavior defaults to true.
Default theme CSS
@slithy/modal-css/css is a small opt-in stylesheet (fade + slight slide-up on the content, fade on the backdrop) — entirely optional. It lives in its own cascade layer (@layer slithy-modal-css-defaults), so any unlayered CSS you write beats it automatically regardless of import order — no specificity fights, no !important needed to override it. Skip the import and supply your own transition rules on [data-slithy="modal-content"]/[data-slithy="modal-backdrop"] instead if you'd rather not use it at all.
ModalRenderer props
| Prop | Type | Description |
| --- | --- | --- |
| inertBackgroundRef | React.RefObject<HTMLElement \| null> | Optional. While any modal is open, marks this element inert (and aria-hidden) so assistive technology can't reach content behind the modal. Forwarded through to @slithy/modal-kit's ModalRenderer |
| renderLayer | (children) => ReactNode | Wrap all modals in a layer context |
| renderPortal | (children) => ReactNode | Wrap each modal in a portal |
| skipAnimation | boolean | Skip all animations. Intended for test environments |
| zIndex | number \| string | Optional z-index applied to each rendered modal container |
| backdropZIndex | number \| string | Optional z-index applied to the backdrop |
| entranceStrategy | 'auto' \| 'starting-style' \| 'reflow' | How the backdrop's entrance transition gets its "from" values on first paint. See Entrance strategies. @default 'auto' |
| transitionTimeoutMs | number | Safety net (ms) that resolves the backdrop's transition phase if transitionend never fires. @default 500 |
When using @slithy/layers, reuse the same explicit layer value at the app surface: pass it to LayerProvider zIndex={...} for logical priority and to ModalRenderer zIndex={...} for actual CSS stacking.
Usage
import { useModalStore } from "@slithy/modal-core";
import { Modal } from "@slithy/modal-css";
function MyButton() {
const open = (event: React.MouseEvent) => {
useModalStore.getState().openModal(
<Modal aria-label="My Modal">
<p>Content</p>
</Modal>,
{ triggerEvent: event },
);
};
return <button onClick={open}>Open</button>;
}Every modal renders with data-state (closed | opening | open | closing) and data-slithy-adapter="css" on its content node — write your own transition targeting those attributes instead of (or alongside) the default theme:
[data-slithy="modal-content"][data-slithy-adapter="css"] {
opacity: 1;
transform: scale(1);
transition: opacity 200ms ease, transform 200ms ease;
}
[data-slithy="modal-content"][data-slithy-adapter="css"][data-state="closed"],
[data-slithy="modal-content"][data-slithy-adapter="css"][data-state="closing"] {
opacity: 0;
transform: scale(0.96);
}data-slithy-adapter="css" scopes these rules to modals rendered by this package specifically — without it, an app that also uses modal-kit or modal-spring (which share the same unprefixed data-slithy DOM convention by design) would have these transitions apply to those adapters' modals too.
Modal Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| aria-label | string | — | Accessible name for the dialog. Warns via console.warn if neither this nor aria-labelledby is set |
| aria-labelledby | string | — | Points to an existing element's ID for the accessible name, instead of duplicating text into aria-label |
| alignX | 'center' \| 'left' \| 'right' | 'center' | Horizontal position |
| alignY | 'middle' \| 'top' \| 'bottom' | 'middle' | Vertical position |
| dismissible | boolean | true | Allow Escape and backdrop-click to close |
| contentClassName | string | — | Class on the <dialog> element |
| contentStyle | CSSProperties | — | Static styles on the <dialog> element |
| disableOpacityTransition | boolean | — | Skip the default opacity fade |
| entranceStrategy | 'auto' \| 'starting-style' \| 'reflow' | 'auto' | How the entrance transition gets its "from" values on first paint. See Entrance strategies |
| initialFocusRef | React.RefObject<HTMLElement \| null> | — | Focus this element instead of the dialog on open (and when regaining focus after a sibling above closes). Must be inside the dialog, or falls back to the dialog itself |
| layerIsActive | boolean | true | Pass from useLayerState for layer coordination |
| transitionTimeoutMs | number | 500 | Safety net (ms) that resolves a transition phase if transitionend never fires — e.g. your CSS defines no transition |
| afterOpen | () => void | — | Fires after enter animation completes |
| afterClose | () => void | — | Fires after modal is removed |
For layered coordination, pass layerIsActive from useLayerState((s) => s.layerIsActive) (typically from @slithy/layers).
Entrance strategies
CSS transitions only animate between two distinct rendered states — a node that mounts already in its final state has nothing to transition from. entranceStrategy picks how the "from" values are supplied on first paint:
| Strategy | How it works | Requires |
| --- | --- | --- |
| starting-style | Mounts directly in the open state; the @starting-style at-rule supplies the "from" values for one frame before the browser's first real paint | Chrome 117+, Safari 17.5+, Firefox 129+ |
| reflow | Mounts at data-state="closed" for one frame, then flips to "opening" on the next frame — a genuine state change the browser transitions between | No modern-CSS requirement; works everywhere |
| auto (default) | Picks starting-style when the browser supports it, reflow otherwise | — |
The default theme's CSS (@slithy/modal-css/css) is written to work correctly under either strategy — see the comments in default.css if you're writing your own transitions and want the same dual support.
Mobile bottom sheet
The same responsive pattern documented in @slithy/modal-spring's README — a centered dialog on desktop, a full-height sheet sliding up from the bottom on mobile — done entirely in CSS instead of a JS animation-config prop. That's the actual value of a CSS-transition adapter: no contentTransitions-style prop exists here, because plain media queries already do the job.
The one wrinkle: the animated node (ModalContent, where data-state lives) is the dialog's parent, not the dialog itself, so a per-modal override needs to reach from the dialog (which carries data-modalid) up to it. :has() does that:
import { useBreakpoint } from "@slithy/utils";
import { Modal, useModalState } from "@slithy/modal-css";
function MyModal() {
const breakpointMD = useBreakpoint(768);
const modalId = useModalState((s) => s.modalId);
return (
<Modal
aria-label="My Modal"
alignX={breakpointMD ? undefined : "center"}
alignY={breakpointMD ? undefined : "bottom"}
contentClassName="my-sheet"
disableOpacityTransition={!breakpointMD}
>
<style href={`modal-${modalId}`} precedence="component">{`
[data-modalid="${modalId}"].my-sheet {
width: 100%;
max-width: 100%;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
@media (max-width: 767px) {
[data-modalid="${modalId}"].my-sheet {
height: 100dvh;
}
}
@media (min-width: 768px) {
[data-modalid="${modalId}"].my-sheet {
width: 480px;
height: calc(100vh - 96px);
margin: 48px;
}
}
/* Bottom-sheet entrance override — reaches from the content wrapper
(where transform/data-state live) down to this dialog via :has(),
scoped to this modal instance by data-modalid. */
@media (max-width: 767px) {
[data-slithy="modal-content"][data-slithy-adapter="css"]:has(
> [data-modalid="${modalId}"]
) {
@starting-style {
transform: translateY(100%);
}
}
[data-slithy="modal-content"][data-slithy-adapter="css"]:has(
> [data-modalid="${modalId}"]
)[data-state="closed"],
[data-slithy="modal-content"][data-slithy-adapter="css"]:has(
> [data-modalid="${modalId}"]
)[data-state="closing"] {
transform: translateY(100%);
}
}
`}</style>
{children}
</Modal>
);
}Notes:
- Include both the
@starting-styleblock and the[data-state]rule — one covers each of the two entrance strategies, and only providing one leaves the other strategy's browsers with the default theme's smaller slide instead of the full off-screen one. translateY(100%)moves the sheet by its own height, not the viewport's — it clears fully off-screen regardless of how tall the content is, and needs no viewport-unit math.disableOpacityTransitionis set alongside the mobile override for the same reason as themodal-springexample: a sheet sliding fully off-screen doesn't need an opacity fade layered on top.height: 100dvh(not100vh) tracks the browser's dynamic viewport, so the sheet fills the actual visible area even as a mobile browser's own toolbar hides/shows on scroll. Give the dialog an explicit, definite height at whichever breakpoint needs internal scrolling — a percentage height on a scrolling child (see@slithy/modal-parts'sMain) never resolves against anauto-height ancestor.
useCssTransitionLifecycle
The hook this package's Modal/ModalRenderer are built on. Exported for building a custom adapter around the same enter/exit machinery without modal-specific coupling — it takes a boolean plus config and nothing else, the same shape as react-spring's useTransition.
import { useCssTransitionLifecycle } from "@slithy/modal-css";
const { dataState, transitionRef } = useCssTransitionLifecycle({
open, // caller-controlled presence flag
entranceStrategy, // 'auto' | 'starting-style' | 'reflow', default 'auto'
skipAnimation, // resolve each phase synchronously; reduced-motion is OR'd in automatically
transitionTimeoutMs, // safety net if transitionend never fires, default 500
onEntranceComplete, // fires once when the enter transition finishes
onExitComplete, // fires once when the exit transition finishes
});dataState is 'closed' | 'opening' | 'open' | 'closing' — spread it onto your animated node as data-state={dataState}. transitionRef must be attached to that same node; its own transitionend event is what resolves each phase. When your CSS transitions multiple properties with unequal durations (e.g. opacity 150ms, transform 400ms), completion is gated on the single longest-duration declared property, not whichever fires first — so a phase doesn't resolve (and, on exit, unmount) mid-animation.
useModalState
Reads per-modal context from inside a modal component. Re-exported from @slithy/modal-kit for convenience.
import { useModalState } from "@slithy/modal-css";
const modalId = useModalState((s) => s.modalId);See @slithy/modal-kit docs for the full API.
Accessibility
Checked against the WAI-ARIA APG Dialog (Modal) pattern and relevant WCAG 2.1/2.2 success criteria. modal-css reuses modal-kit's useModalLogic/useDialogKeyDown/focus-trap machinery in full, so most of this table matches @slithy/modal-kit's — the differences here are animation-specific.
| Requirement | Status | Details |
|---|---|---|
| Role | ✅ Full | Same as modal-kit: native <dialog open>, never .showModal(). Native top-layer/focus-trap/::backdrop aren't available; hand-rolled equivalents apply here too |
| aria-modal | ✅ Full | Same as modal-kit — hard-coded aria-modal="true" |
| Accessible name (WCAG 4.1.2) | ✅ Full | Modal accepts aria-label or aria-labelledby. Warns via console.warn if neither is set |
| Initial focus (WCAG 2.4.3) | ✅ Full | Defaults to focusing the dialog container itself once the enter transition completes (or immediately with skipAnimation/reduced motion) — a valid APG option. Configurable via initialFocusRef, same as modal-kit |
| Focus containment (WCAG 2.1.2) | ✅ Full | Same useTrapFocus behavior as modal-kit, including the zero-focusable-elements case |
| Escape to close (WCAG 2.1.1) | ✅ Full | Same document-level listener as modal-kit — works even before the entrance transition completes. dismissible={false} disables it |
| Focus return (WCAG 2.4.3) | ✅ Full | Same as modal-kit |
| Background inertness | ⚠️ Opt-in | Pass inertBackgroundRef to ModalRenderer (forwarded through to modal-kit's ModalRenderer). Without it, only aria-modal="true" protects the background, which isn't reliably honored by all screen readers |
| Nested/stacked modals | ✅ Full | Same cascading Escape/veil/refocus behavior as modal-kit. A covered modal shows a static, unanimated veil — no transition, since a covered/uncovered flip isn't a value worth animating |
| Reduced motion | ✅ Full | Automatically honors the OS-level prefers-reduced-motion: reduce setting (via @slithy/utils's usePrefersReducedMotion), no opt-in required — the enter/leave transition and backdrop both become immediate |
Exports
| Export | Description |
| --- | --- |
| Modal | CSS-transition modal component |
| ModalProps | — |
| ModalRenderer | Renders all open modals with a transitioning backdrop |
| ModalRendererProps | — |
| ModalBackdrop | Transitioning backdrop (used by ModalRenderer) |
| ModalBackdropProps | — |
| ModalVeil | Re-export from @slithy/modal-kit — overlay that dims a backgrounded modal when another opens in front of it. Unanimated; modal-css has no adapter-specific veil |
| ModalVeilProps | — |
| useCssTransitionLifecycle | The enter/exit lifecycle hook Modal/ModalRenderer are built on — see above |
| UseCssTransitionLifecycleOptions | — |
| UseCssTransitionLifecycleResult | — |
| EntranceStrategy | 'auto' \| 'starting-style' \| 'reflow' |
| CssTransitionDataState | 'closed' \| 'opening' \| 'open' \| 'closing' |
| useModalState | Per-modal context hook — re-exported from modal-kit |
| useModalStore | Re-export from @slithy/modal-core |
| ModalElement | Re-exported modal shape type from @slithy/modal-core |
| ModalState | Re-exported 'opening' \| 'open' \| 'closing' \| 'closed' from @slithy/modal-core |
| ModalStore | Re-exported store type from @slithy/modal-core |
| ModalTriggerEvent | Re-exported event type from @slithy/modal-core |
