@slithy/modal-motion
v0.1.1
Published
Motion (motion.dev) animation adapter for @slithy/modal-kit.
Downloads
63
Readme
@slithy/modal-motion
Animated modal adapter for @slithy/modal-kit, built on Motion (motion.dev).
Provides animated enter/leave transitions, an animated backdrop, and native drag-to-close gesture support built on Motion's own drag implementation.
Installation
pnpm add @slithy/modal-core @slithy/modal-kit @slithy/modal-motion
pnpm add "motion@>=12 <13"motion (v12.x) is a peer dependency.
Setup
import { LayerProvider, LayerStackPriority } from "@slithy/layers";
import { Portal } from "@slithy/portal";
import { ModalRenderer } from "@slithy/modal-motion";
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-motion";
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 |
| contentVariants | { closed?, enter?, exit? } | — | Motion variant targets for the closed/enter/exit animation phases. Each is merged over the built-in opacity fade, keyed by Motion's own variant labels. This prop accepts an object, so define it outside the component (or with useMemo) to keep the reference stable across renders. @example const variants = { closed: { y: '100%' }, enter: { y: '0%' }, exit: { y: '100%' } } |
| disableOpacityTransition | boolean | — | Skip the default opacity fade |
| dragDirection | DragDirection | — | Which edge the dialog is dragged 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 |
| transitionConfig | Transition | — | Override the default Motion transition (spring or tween 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-motion";
<Modal dragDirection="down">
<DragHandle>
<Header>Drag me down to dismiss</Header>
</DragHandle>
<p>Content</p>
</Modal>;That external usage is identical to modal-spring's — by design, so an app can swap adapters without touching call sites — but the internal mechanism is different. modal-spring wires a @use-gesture/react bind() spread directly onto the drag surface; this package mounts Motion's drag on the dialog itself with dragListener={false}, and DragHandle starts the gesture imperatively via dragControls.start(event) from its own onPointerDown. That's what restricts dragging to the handle rather than the whole dialog surface.
Dragging past a distance or velocity threshold closes the modal and flies the card off-screen in dragDirection, driven by Motion's own animate(); releasing before either threshold lets Motion's dragConstraints/dragElastic snap 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 onPointerDown handler, or call useModalDragging directly to drive the gesture and fly-out/snap-back animations yourself.
Known limitation — multi-touch cancellation. modal-spring's gesture library exposes live touch-count, so a second finger landing mid-drag can snap the in-progress visual back instantly. Motion's drag gives no equivalent live hook or cancel API — this package can only guarantee the end-user-visible outcome: a two-finger drag never dismisses (the release is forced to snap back rather than evaluating the distance/velocity threshold). The live drag visual during the multi-touch window itself may briefly track two competing pointers less precisely than modal-spring's before that forced snap-back kicks in on release. This is a Motion API limitation, not a bug — documented here so it isn't mistaken for one.
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 — contentVariants overrides Motion's y value, transitionConfig swaps in a sheet-appropriate feel, and plain responsive CSS handles sizing (Motion only owns the animated properties, not layout).
See also: modal-spring/README.md#mobile-bottom-sheet and modal-css/README.md#mobile-bottom-sheet for this same pattern in the other two adapters.
import { useBreakpoint } from "@slithy/utils";
import { Modal, iosSheetTransition, useModalState } from "@slithy/modal-motion";
const MOBILE_VARIANTS = {
closed: { y: "100%" },
enter: { y: "0%" },
exit: { 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"
contentVariants={breakpointMD ? undefined : MOBILE_VARIANTS}
disableOpacityTransition={!breakpointMD}
transitionConfig={breakpointMD ? undefined : iosSheetTransition}
>
<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 variants — 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. iosSheetTransitionis tuned for this exact motion (a 0.5s tween on the iOS/Ionic sheet cubic-bezier); swap in your ownTransitionfor 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-motion";
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-motion 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 animation's onAnimationComplete('enter') 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 animation-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 — resolved synchronously rather than relying on Motion's own reduced-motion handling matching this repo's semantics exactly. The enter/leave transition and backdrop both 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 { onPointerDown, active } from context |
| useModalDragging | Drives the drag gesture and fly-out/snap-back animations for one modal — for custom drag UIs |
| UseModalDraggingOptions | Options type for useModalDragging |
| UseModalDraggingResult | Return type of useModalDragging |
| ModalDragProps | Props shape spread onto the draggable dialog |
| ModalDragMotionStyle | { x, y } Motion values driving the live drag transform |
| useModalStore | Re-export from @slithy/modal-core |
| useModalState | Per-modal context hook — re-exported from modal-kit |
| defaultTransition | Default Motion transition config |
| iosSheetTransition | iOS/Ionic-feel tween transition (0.5s, cubic-bezier [0.32, 0.72, 0, 1]) |
| DragDirection | 'up' \| 'down' \| 'left' \| 'right' |
| 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 |
