@slithy/modal-kit
v0.8.0
Published
Headless modal React components for @slithy/modal-core.
Readme
@slithy/modal-kit
The headless React layer for the @slithy modal system. Provides primitives, behavior hooks, and a renderer — everything needed to build a fully functional modal UI with no animation dependency.
modal-kit + modal-core are sufficient for a production modal system. Add an animation adapter (or a custom one) only if you need animated transitions.
Adapters
All three wrap the same modal-kit primitives and expose the same core Modal/ModalRenderer API — pick based on which animation approach fits your app, or how much you want to depend on. See Building an Adapter below for the underlying contract they all implement, and each package's own README for its full API.
| Package | Approach | Dependencies | Notes |
|---|---|---|---|
| @slithy/modal-spring | react-spring physics | @react-spring/web, @use-gesture/react | Drag-to-dismiss via @use-gesture/react's live gesture tracking |
| @slithy/modal-css | Plain CSS transitions | None (zero runtime deps beyond modal-kit/modal-core) | No drag-to-dismiss; animation lives entirely in your own CSS |
| @slithy/modal-motion | Motion (motion.dev) | motion | Drag-to-dismiss via Motion's native drag/dragControls; structurally closest to modal-spring |
modal-css is the right choice if you want zero animation-library dependencies and are comfortable writing the transition as CSS. modal-spring and modal-motion both offer drag-to-dismiss with a similar prop shape (dragDirection + DragHandle) — the difference is mainly dependency footprint and animation-library preference, not capability; modal-motion's drag has one documented limitation modal-spring's doesn't (no live multi-touch cancel, only a forced snap-back on release — see its README's Drag to dismiss section).
Installation
pnpm add @slithy/modal-core @slithy/modal-kitMinimal Setup (no animation)
import { useModalStore } from '@slithy/modal-core'
import { LayerProvider, LayerStackPriority } from '@slithy/layers'
import { Modal, ModalRenderer } from '@slithy/modal-kit'
import { ModalBackdrop } from '@slithy/modal-kit'
// Render once at your app root
export function App() {
const { backdropId, modals } = useModalStore(({ backdropId, modals }) => ({
backdropId,
modals,
}))
const showBackdrop = !!backdropId && modals.length > 0
const modalZ = LayerStackPriority.Modal
return (
<LayerProvider id="app" zIndex={LayerStackPriority.App}>
<main>{/* your app */}</main>
<ModalRenderer
renderBackdrop={() =>
showBackdrop ? <ModalBackdrop style={{ zIndex: modalZ - 1 }} /> : null
}
renderLayer={(children) => (
<LayerProvider id="modal" zIndex={modalZ}>
{children}
</LayerProvider>
)}
zIndex={modalZ}
/>
</LayerProvider>
)
}
// Open a modal from anywhere
useModalStore.getState().openModal(
<Modal aria-label="My Modal">
<p>Content</p>
</Modal>,
{ triggerEvent: event }
)Primitives
These are the building blocks. Each can be composed directly or wrapped by animation adapters.
ModalContainer
Fixed-position layout shell. Handles horizontal alignment.
<ModalContainer alignX="center" modalId={id}>
{/* ModalContent, etc. */}
</ModalContainer>| Prop | Type | Default |
|---|---|---|
| alignX | 'center' \| 'left' \| 'right' | 'center' |
| modalId | string | — |
| onBackdropClick | () => void | — |
| className | string | — |
ModalContent
Content wrapper. Controls vertical alignment, pointer events during close, and the data-disable-opacity toggle.
<ModalContent alignY="middle" modalState={modalState} disableOpacityTransition>
{/* ModalDialog, etc. */}
</ModalContent>| Prop | Type | Default |
|---|---|---|
| alignY | 'middle' \| 'top' \| 'bottom' | — |
| modalState | ModalState | — |
| disableOpacityTransition | boolean | — |
| style | CSSProperties | — |
ModalDialog
The <dialog open> element with correct ARIA attributes, focus target, and modal-dialog styles.
<ModalDialog
ref={contentRef}
aria-label="My Modal"
modalId={modalId}
onKeyDown={onKeyDown}
>
{children}
</ModalDialog>| Prop | Type |
|---|---|
| aria-label | string |
| aria-labelledby | string |
| className | string |
| modalId | string |
| onKeyDown | (e: KeyboardEvent<HTMLDialogElement>) => void |
| style | CSSProperties |
ModalBackdrop
Fixed-position backdrop with click handling via usePointerClick.
<ModalBackdrop onClick={handleClose} />| Prop | Type |
|---|---|
| onClick | () => void |
| className | string |
| style | CSSProperties |
ModalVeil
Absolute-position overlay that dims a backgrounded modal when another modal opens in front of it. A pure visual primitive — store logic lives in the consuming Modal.
<ModalVeil style={style} />| Prop | Type |
|---|---|
| style | CSSProperties |
Components
Modal
Reference implementation. Composes all four primitives into a functional non-animated modal. Use it directly for a no-animation setup, or as a reference when building a custom adapter.
<Modal
aria-label="Settings"
alignX="center"
alignY="middle"
contentClassName="my-card"
contentStyle={{ borderRadius: 12 }}
dismissible
>
{children}
</Modal>| 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 |
| children | ReactNode | — | — |
| contentClassName | string | — | Class on the <dialog> element |
| contentStyle | CSSProperties | — | Inline styles on the <dialog> element |
| disableOpacityTransition | boolean | — | Skip the default opacity fade |
| dismissible | boolean | true | Allow Escape and backdrop-click to close |
| 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 | Whether this modal's layer is active; pass from useLayerState for full layer coordination |
| onLeave | (done: () => void) => void | — | Called when close begins; call done() after leave animation |
| afterOpen | () => void | — | Fires after mount |
| afterClose | () => void | — | Fires after modal is removed |
ModalRenderer
Renders all open modals from the store. Place once at the app root.
<ModalRenderer
renderBackdrop={() => showBackdrop ? <ModalBackdrop /> : null}
renderLayer={(children) => <LayerProvider>{children}</LayerProvider>}
renderPortal={(children) => <Portal>{children}</Portal>}
zIndex={LayerStackPriority.Modal}
/>| Prop | Type | Description |
|---|---|---|
| inertBackgroundRef | React.RefObject<HTMLElement \| null> | Optional. While any modal is open, marks this element inert (and aria-hidden, for engines that don't support inert) so assistive technology can't reach content behind the modal — aria-modal="true" alone isn't reliably honored by all screen readers |
| renderBackdrop | () => ReactNode | Backdrop; caller controls visibility and animation. Not wrapped by renderPortal — portal it yourself here if ModalRenderer sits under a transformed/stacking-context ancestor |
| renderLayer | (children) => ReactNode | Wrap all modals in a layer context |
| renderPortal | (children) => ReactNode | Wrap each modal in a portal |
| skipAnimation | boolean | Skip all modal animations. Intended for test environments |
| zIndex | number \| string | Optional z-index applied to each rendered modal container |
@slithy/layers is optional. If you omit renderLayer, modals run in standalone mode.
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.
Layout Patterns
modal-kit supports two layout patterns. Pick one per modal and make scroll ownership explicit.
Document modal (long document-style)
Use this when the dialog should behave like a document or page.
- The modal container or overlay is the scroll surface.
- The dialog stays unconstrained and should not create its own inner scroll region by default.
- Best for about pages, help screens, legal notices, long forms, changelogs, and other content meant to read continuously.
This is the default mental model for a long modal: the user scrolls the modal surface, not an inner panel.
Card modal (card-style)
Use this when the dialog should behave like a bounded card.
- The dialog has a fixed height or a
max-heightrelative to the viewport. - The dialog owns an internal scroll region, usually in the body or content area.
- Best for compact tasks, confirmations, settings panels, pickers, and utility workflows where header and actions should remain visually stable.
Document modal and Card modal are good names to use in app or design-system docs.
Implementation tradeoff
- If the dialog is unconstrained, the modal container can scroll around it.
- If the dialog is height-constrained, you must create and wire an internal scroll region yourself.
- App-level
html, body { overflow: hidden; }does not prevent the modal container from scrolling when the modal system is implemented correctly, because the modal container is its own fixed positioned scroll surface.
Choosing a pattern
| Choose | When | Scroll owner |
|---|---|---|
| Document modal | The content should read like one continuous page | ModalContainer |
| Card modal | The frame should stay stable while only the body scrolls | Inner content region inside ModalDialog |
Common mistakes
- Do not add
overflow: hiddento the dialog unless you also provide a scrollable inner body. - Do not constrain dialog height accidentally and still expect container scrolling to work.
- Do not mix both patterns in one modal. If both the container and an inner body can scroll, the result is usually confusing.
Example: document modal
The container scrolls around a long dialog. The dialog is not height-capped and does not own an internal scroll body.
import { Modal } from '@slithy/modal-kit'
import { Header } from '@slithy/modal-parts'
export function ChangelogModal() {
return (
<Modal
aria-label="Changelog"
alignY="top"
contentClassName="document-modal"
>
<Header>Changelog</Header>
<div className="document-modal-body">
<p>...</p>
<p>...</p>
<p>...</p>
</div>
</Modal>
)
}.document-modal {
margin: 2rem auto;
max-width: 48rem;
}
.document-modal-body {
padding: 1.25rem;
}Do:
- Keep the dialog naturally sized by its content.
- Let the modal container handle scrolling.
Do not:
- Add a capped height and inner
overflow-y: autounless you are intentionally switching to the card pattern.
Example: card modal
The dialog is capped to the viewport, and the body owns scrolling internally.
import { Modal } from '@slithy/modal-kit'
import { CloseButton, Footer, Header, Main } from '@slithy/modal-parts'
import { useModalStore } from '@slithy/modal-core'
export function SettingsModal() {
const closeModal = useModalStore((s) => s.closeModal)
return (
<Modal
aria-label="Settings"
alignY="middle"
contentClassName="card-modal"
>
<Header>Settings</Header>
<CloseButton handleClose={() => closeModal()} />
<Main>
<p>...</p>
<p>...</p>
<p>...</p>
</Main>
<Footer>Actions</Footer>
</Modal>
)
}.card-modal {
display: flex;
flex-direction: column;
max-height: min(42rem, calc(100vh - 2rem));
width: min(40rem, calc(100vw - 2rem));
}Here Main is the scroll owner. Header, CloseButton, and Footer remain visually stable while the body scrolls.
Styling
The <dialog> element is the primary styling target. Three approaches are available, and can be combined.
contentClassName
Pass a class name to the dialog for variant-level styles:
<Modal contentClassName="my-modal">...</Modal>.my-modal {
border-radius: 12px;
max-width: 480px;
}contentStyle
Pass inline styles for one-off overrides:
<Modal contentStyle={{ maxWidth: 640 }}>...</Modal>data-modalid (per-instance scoping)
Every <dialog> receives a data-modalid attribute set to the modal's store ID. Use it to scope styles to a specific instance — useful with React 19's <style href> deduplication when multiple modal types are open simultaneously:
function MyModal() {
const modalId = useModalState((s) => s.modalId);
return (
<Modal aria-label="Settings">
<style href={`modal-${modalId}`} precedence="component">{`
[data-modalid="${modalId}"] {
border-radius: 12px;
& .modal-header { font-size: 1.25rem; }
}
`}</style>
{children}
</Modal>
);
}Because href is derived from the runtime modalId, each open modal gets its own deduplicated style block, and styles from different modal types never collide.
CSS Variables
--slithy-modal-backdrop-color
Controls the background color of ModalBackdrop and ModalVeil. Set it anywhere in the cascade — both components use it as a fallback-safe var().
/* default */
--slithy-modal-backdrop-color: rgba(0, 0, 0, 0.5);/* override per-page or per-modal */
:root {
--slithy-modal-backdrop-color: rgba(15, 10, 30, 0.6);
}--slithy-modal-dialog-bg
Controls the background color of ModalDialog. Falls back to the Canvas system color.
/* default */
--slithy-modal-dialog-bg: Canvas;/* override per-page or per-modal */
:root {
--slithy-modal-dialog-bg: #1a1a1a;
}Hooks
useModalLogic
The core modal hook. Manages registration, lifecycle, focus, and Escape key. Called by Modal and by animation adapters.
const {
canHandleEscape, // whether this modal may currently handle Escape (see useDialogKeyDown below)
contentRef, // ref to attach to <dialog>
handleCloseModal, // call to begin the close sequence
isTopModal, // whether this is the frontmost modal
layerIsActive, // whether this modal's layer is active
markAtRest, // call once enter animation completes to unblock closing
modalId, // this modal's store ID
modalState, // 'opening' | 'open' | 'closing' | 'closed'
skipAnimation, // whether animations are skipped (from ModalRenderer prop)
} = useModalLogic({
afterClose,
initialFocusRef, // focus this element instead of the dialog on open; must be inside it
layerIsActive, // pass from useLayerState for full layer coordination
manageFocus, // false to skip focus management (e.g. adapter handles its own trap)
onLeave, // (done) => void — call done() after leave animation
})useModalState
Reads per-modal context set by ModalProvider. Use this inside any component rendered as a modal to access the modal's own ID and state.
const modalId = useModalState((s) => s.modalId)
const modalState = useModalState((s) => s.state)| Field | Type | Description |
|---|---|---|
| modalId | string | This modal's store ID |
| state | ModalState | 'opening' \| 'open' \| 'closing' \| 'closed' |
| enqueuedToClose | boolean | Whether a close has been requested |
| skipAnimation | boolean \| undefined | From ModalRenderer prop |
| triggerElement | HTMLElement \| undefined | The element that triggered this modal |
| zIndex | CSSProperties['zIndex'] | From ModalRenderer's zIndex prop |
Must be called inside a component rendered within ModalRenderer. The most common use is reading modalId to pass to closeModal:
const modalId = useModalState((s) => s.modalId)
useModalStore.getState().closeModal(modalId)useDialogKeyDown
Registers Escape-to-close as a document-level keydown listener — not via onKeyDown bubbling — so it works even before focus has moved into the dialog (e.g. mid-entrance-animation). Also returns an onKeyDown callback to attach to the <dialog> for composing in useTrapFocus (or any other keydown behavior), since Tab-trapping only needs to act once focus is already inside.
const onKeyDown = useDialogKeyDown({
canHandleEscape, // useModalLogic's canHandleEscape — not isTopModal; see below
dismissible,
handleCloseModal,
layerIsActive,
onKeyDown: trapFocus.onKeyDown,
})canHandleEscape (from useModalLogic) is distinct from isTopModal: it stays false for a modal that's mid-close-animation, so Escape can reach the modal beneath it in a stack instead of being swallowed until the closing modal is fully removed.
Standalone vs layered mode
- Standalone (no
@slithy/layers):layerIsActivedefaults totrue. - Layered: pass
layerIsActivefromuseLayerState((s) => s.layerIsActive)so Escape handling is gated by your active layer stack.
Building an Adapter
An adapter:
- Calls
useModalLogicand passesonLeavefor leave animation control - Wraps
ModalContent,ModalDialog, etc. with its animation library (e.g.animated(ModalDialog)) - Calls
markAtRest()when the enter animation completes (unblocks closing and transitions state to'open') - Calls
afterOpen?.()alongsidemarkAtRest()in the enteronRestcallback - Calls
done()in the leaveonRestcallback to remove the modal - Wraps
ModalRendererwithrenderBackdropto inject an animated backdrop
See @slithy/modal-spring, @slithy/modal-css, or @slithy/modal-motion for complete examples — the three existing adapters (see Adapters above).
Accessibility
Checked against the WAI-ARIA APG Dialog (Modal) pattern and relevant WCAG 2.1/2.2 success criteria.
| Requirement | Status | Details |
|---|---|---|
| Role | ✅ Full | ModalDialog renders a native <dialog open> (implicit role="dialog") — never .showModal(), so the native top-layer, native focus trap, and native ::backdrop are not available. Those are hand-rolled instead: focus trap via useTrapFocus, backdrop via ModalContainer/ModalBackdrop, background inertness via inertBackgroundRef (see below) |
| aria-modal | ✅ Full | Hard-coded aria-modal="true" on the dialog, not overridable |
| Accessible name (WCAG 4.1.2) | ✅ Full | Modal accepts aria-label or aria-labelledby. If a Modal mounts with neither, ModalDialog logs a console.warn |
| Initial focus (WCAG 2.4.3) | ✅ Full | Defaults to focusing the dialog container itself via markAtRest (a valid APG option). Configurable via initialFocusRef — point it at a specific element (e.g. the first input, or a primary action) and it's focused instead, both on open and when regaining focus after a sibling above closes. Falls back to the container if the ref is unset or points outside the dialog |
| Focus containment (WCAG 2.1.2) | ✅ Full | useTrapFocus traps Tab/Shift+Tab at the first/last focusable element, including the zero-focusable-elements case (preventDefault() keeps focus on the container instead of letting it escape) |
| Escape to close (WCAG 2.1.1) | ✅ Full | Document-level keydown listener — works even before focus has moved into the dialog (e.g. mid-entrance-animation in an animated adapter). dismissible={false} disables it; ensure another close affordance (e.g. CloseButton) exists in that case |
| Focus return (WCAG 2.4.3) | ✅ Full | Returns focus to the triggering element on close, only if focus was actually inside the modal (or nowhere meaningful) when close was requested; falls back gracefully if the trigger was removed from the DOM |
| Background inertness | ⚠️ Opt-in | Pass inertBackgroundRef to ModalRenderer to mark background content inert + aria-hidden while a modal is open. Without it, aria-modal="true" is the only protection, which isn't reliably honored by all screen readers — background content may remain reachable by assistive technology |
| Nested/stacked modals | ✅ Full | Only the topmost non-closing modal handles Escape; a ModalVeil dims covered modals; focus returns to the modal beneath one that closes |
| Reduced motion | N/A | modal-kit's Modal has no animation. See your chosen adapter's own Accessibility section (modal-spring, modal-css, modal-motion) if you're using one |
Exports
| Export | Description |
|---|---|
| Modal | Reference non-animated modal component |
| ModalBackdrop | Fixed-position backdrop primitive |
| ModalContainer | Layout shell primitive |
| ModalContent | Content wrapper primitive |
| ModalDialog | <dialog> element primitive |
| ModalRenderer | Renders all open modals from the store |
| ModalVeil | Absolute-position veil overlay primitive |
| useDialogKeyDown | Escape + trapFocus key handler hook |
| useModalLogic | Core lifecycle and behavior hook |
| useModalState | Per-modal context hook (ID, state, triggerElement) |
| useModalStore | Re-export from @slithy/modal-core |
| ContainerProps | — |
| ModalBackdropProps | — |
| ModalContentProps | — |
| ModalDialogProps | — |
| ModalProps | — |
| ModalRendererProps | — |
| ModalVeilProps | — |
| 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 |
| UseModalLogicOptions | — |
| UseModalLogicResult | — |
