@slithy/modal-spring
v0.7.1
Published
React Spring animation adapter for @slithy/modal-kit.
Downloads
915
Readme
@slithy/modal-spring
Animated modal adapter for @slithy/modal-kit, built on react-spring and @use-gesture/react.
Provides animated enter/leave transitions, an animated backdrop, and drag-to-close gesture support.
Installation
pnpm add @slithy/modal-core @slithy/modal-kit @slithy/modal-spring
pnpm add "@react-spring/web@>=10 <11" "@use-gesture/react@>=10 <11"@react-spring/web and @use-gesture/react (both v10.x) are peer dependencies.
Setup
import { LayerProvider, LayerStackPriority } from "@slithy/layers";
import { Portal } from "@slithy/portal";
import { ModalRenderer } from "@slithy/modal-spring";
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.
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 animated backdrop |
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-spring";
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>;
}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 |
| contentTransitions | { from, enter, leave } | — | Spring transition values |
| disableOpacityTransition | boolean | — | Skip the default opacity fade |
| dragDirection | DragDirection | — | Edge to drag toward to dismiss. Requires a DragHandle child to actually enable the gesture — see Drag to dismiss |
| 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 |
| springConfig | SpringConfig | — | Override the default spring config |
| 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).
Drag to dismiss
Two things are required together — dragDirection alone does nothing:
- Pass
dragDirectiontoModal('down' | 'up' | 'left' | 'right'). This is what mounts the drag machinery. - Wrap the element that should be draggable in
DragHandle, rendered somewhere insideModal's children.
import { CloseButton, Header } from "@slithy/modal-parts";
import { DragHandle, Modal } from "@slithy/modal-spring";
<Modal dragDirection="down">
<DragHandle>
<Header>Drag me down to dismiss</Header>
</DragHandle>
<p>Content</p>
</Modal>;Dragging past a distance or velocity threshold closes the modal and flies the card off-screen in dragDirection; releasing before either threshold snaps it back. DragHandle accepts an enabled prop (defaults true) to opt a specific handle out without removing dragDirection from the modal.
For a fully custom drag surface instead of DragHandle, call useModalDrag() inside a component rendered within Modal's children to get the raw bind function, or call useModalDragging directly to drive the gesture and fly-out spring yourself.
Mobile bottom sheet
A common responsive pattern: a centered dialog on desktop that becomes a full-height sheet sliding up from the bottom on mobile. Three things combine to produce it — contentTransitions overrides the spring's y value, springConfig swaps in a sheet-appropriate feel, and plain responsive CSS handles sizing (spring only owns the animated properties, not layout).
import { useBreakpoint } from "@slithy/utils";
import { Modal, iosSheetSpring, useModalState } from "@slithy/modal-spring";
const MOBILE_TRANSITIONS = {
from: { y: "100%" },
enter: { y: "0%" },
leave: { y: "100%" },
};
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"
contentTransitions={breakpointMD ? undefined : MOBILE_TRANSITIONS}
disableOpacityTransition={!breakpointMD}
springConfig={breakpointMD ? undefined : iosSheetSpring}
>
<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;
}
}
`}</style>
{children}
</Modal>
);
}Notes:
disableOpacityTransitionis set alongside the mobile transitions — a sheet sliding fully off-screen doesn't need an opacity fade on top, and skipping it avoids a double-animation feel.height: 100dvh(not100vh) on the mobile rule —dvhtracks the browser's dynamic viewport (it shrinks/grows as a mobile browser's own toolbar hides/shows on scroll), so the sheet always fills the actual visible area instead of being cut off or leaving a gap.- The dialog's own
height:autofallback (no explicit height set) only applies above768pxin this example, where@media (min-width: 768px)supplies one — always give the dialog an explicit, definite height on whichever breakpoint needs internal scrolling (see@slithy/modal-parts'sMain), since a percentage height on a scrolling child never resolves against anauto-height ancestor. iosSheetSpringis tuned for this exact motion (a slightly overdamped drag-sheet feel); swap in your ownSpringConfigfor a different character.
useModalState
Reads per-modal context from inside a modal component. Re-exported from @slithy/modal-kit for convenience.
import { useModalState } from "@slithy/modal-spring";
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-spring 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, forwarded through AnimatedModalDialog. 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's onRest fires (or immediately with skipAnimation/reduced motion) — a valid APG option. Configurable via initialFocusRef, same as modal-kit. Timing still depends on the animation actually completing, same caveat as any spring-driven UI |
| 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 animation 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, plus animated veil transitions |
| 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, backdrop, and veil all become immediate. For drag-to-dismiss: the live drag itself still tracks the pointer 1:1 (that's direct manipulation, not autoplaying animation), but the follow-through — snap-back if released before the dismiss threshold, fly-out if released past it — completes instantly instead of easing |
Exports
| Export | Description |
| ------------------ | ----------------------------------------------------- |
| Modal | Animated modal component |
| ModalProps | — |
| ModalRenderer | Renders all open modals with animated backdrop |
| ModalRendererProps | — |
| ModalBackdrop | Animated backdrop (used by ModalRenderer) |
| ModalBackdropProps | — |
| ModalVeil | Overlay that dims a backgrounded modal when another opens in front of it |
| ModalVeilProps | — |
| DragHandle | Drag-to-close gesture wrapper — see Drag to dismiss |
| useModalDrag | Low-level drag hook — returns { bind, active } from context |
| useModalDragging | Drives the drag gesture and fly-out spring for one modal — for custom drag UIs |
| UseModalDraggingOptions | Options type for useModalDragging |
| useModalStore | Re-export from @slithy/modal-core |
| useModalState | Per-modal context hook — re-exported from modal-kit |
| defaultSpring | Default spring config |
| iosSheetSpring | iOS-feel spring config |
| DragDirection | 'up' \| 'down' \| 'left' \| 'right' |
| DragStyles | Spring transform values from drag |
| 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 |
