npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. Pass dragDirection to Modal ('down' | 'up' | 'left' | 'right'). This is what mounts the drag machinery.
  2. Wrap the element that should be draggable in DragHandle, rendered somewhere inside Modal'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:

  • disableOpacityTransition is 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 (not 100vh) on the mobile rule — dvh tracks 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:auto fallback (no explicit height set) only applies above 768px in 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's Main), since a percentage height on a scrolling child never resolves against an auto-height ancestor.
  • iosSheetTransition is tuned for this exact motion (a 0.5s tween on the iOS/Ionic sheet cubic-bezier); swap in your own Transition for 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 |