@slithy/modal-kit
v0.5.2
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 @slithy/modal-spring (or a custom adapter) only if you need animated transitions.
Installation
pnpm add @slithy/modal-core @slithy/modal-kitMinimal Setup (no animation)
import { useModalStore } from '@slithy/modal-core'
import { Modal, ModalRenderer } 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
return (
<>
<main>{/* your app */}</main>
<ModalRenderer
renderBackdrop={() => showBackdrop ? <ModalBackdrop /> : null}
/>
</>
)
}
// 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 |
| 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 |
| 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 |
| 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>}
/>| Prop | Type | Description |
|---|---|---|
| renderBackdrop | () => ReactNode | Backdrop; caller controls visibility and animation |
| 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 |
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
--modalBackdropColor
Controls the background color of ModalBackdrop and ModalVeil. Set it anywhere in the cascade — both components use it as a fallback-safe var().
/* default */
--modalBackdropColor: rgba(0, 0, 0, 0.5);/* override per-page or per-modal */
:root {
--modalBackdropColor: rgba(15, 10, 30, 0.6);
}Hooks
useModalLogic
The core modal hook. Manages registration, lifecycle, focus, and Escape key. Called by Modal and by animation adapters.
const {
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,
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 |
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
Composes the Escape-key close handler and useTrapFocus into a single onKeyDown for a <dialog>.
const onKeyDown = useDialogKeyDown({
dismissible,
handleCloseModal,
isTopModal,
layerIsActive,
onKeyDown: trapFocus.onKeyDown,
})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 for a complete example.
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) |
| ModalBackdropProps | — |
| ModalContentProps | — |
| ModalDialogProps | — |
| ModalProps | — |
| ModalRendererProps | — |
| ModalVeilProps | — |
| UseModalLogicOptions | — |
| UseModalLogicResult | — |
