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

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

  • disableOpacityTransition is 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 (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.
  • iosSheetSpring is tuned for this exact motion (a slightly overdamped drag-sheet feel); swap in your own SpringConfig 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-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 |