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

v0.1.1

Published

Plain CSS transition adapter for @slithy/modal-kit.

Readme

@slithy/modal-css

Zero-dependency CSS transition adapter for @slithy/modal-kit. Animates enter/leave with plain CSS transitions instead of a JS animation library — no runtime dependency beyond @slithy/modal-kit and @slithy/utils.

Reach for @slithy/modal-spring instead if you need drag-to-dismiss or spring physics. Reach for this package when you want the animation to live entirely in CSS, or want to keep the dependency footprint minimal.


Installation

pnpm add @slithy/modal-core @slithy/modal-kit @slithy/modal-css

No animation library peer dependency — only react (^18 || ^19).


Setup

import { LayerProvider, LayerStackPriority } from "@slithy/layers";
import { Portal } from "@slithy/portal";
import { ModalRenderer } from "@slithy/modal-css";
import "@slithy/modal-css/css";

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.

Default theme CSS

@slithy/modal-css/css is a small opt-in stylesheet (fade + slight slide-up on the content, fade on the backdrop) — entirely optional. It lives in its own cascade layer (@layer slithy-modal-css-defaults), so any unlayered CSS you write beats it automatically regardless of import order — no specificity fights, no !important needed to override it. Skip the import and supply your own transition rules on [data-slithy="modal-content"]/[data-slithy="modal-backdrop"] instead if you'd rather not use it at all.

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 backdrop | | entranceStrategy | 'auto' \| 'starting-style' \| 'reflow' | How the backdrop's entrance transition gets its "from" values on first paint. See Entrance strategies. @default 'auto' | | transitionTimeoutMs | number | Safety net (ms) that resolves the backdrop's transition phase if transitionend never fires. @default 500 |

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-css";

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>;
}

Every modal renders with data-state (closed | opening | open | closing) and data-slithy-adapter="css" on its content node — write your own transition targeting those attributes instead of (or alongside) the default theme:

[data-slithy="modal-content"][data-slithy-adapter="css"] {
  opacity: 1;
  transform: scale(1);
  transition: opacity 200ms ease, transform 200ms ease;
}

[data-slithy="modal-content"][data-slithy-adapter="css"][data-state="closed"],
[data-slithy="modal-content"][data-slithy-adapter="css"][data-state="closing"] {
  opacity: 0;
  transform: scale(0.96);
}

data-slithy-adapter="css" scopes these rules to modals rendered by this package specifically — without it, an app that also uses modal-kit or modal-spring (which share the same unprefixed data-slithy DOM convention by design) would have these transitions apply to those adapters' modals too.


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 | | disableOpacityTransition | boolean | — | Skip the default opacity fade | | entranceStrategy | 'auto' \| 'starting-style' \| 'reflow' | 'auto' | How the entrance transition gets its "from" values on first paint. See Entrance strategies | | 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 | | transitionTimeoutMs | number | 500 | Safety net (ms) that resolves a transition phase if transitionend never fires — e.g. your CSS defines no transition | | 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).


Entrance strategies

CSS transitions only animate between two distinct rendered states — a node that mounts already in its final state has nothing to transition from. entranceStrategy picks how the "from" values are supplied on first paint:

| Strategy | How it works | Requires | | --- | --- | --- | | starting-style | Mounts directly in the open state; the @starting-style at-rule supplies the "from" values for one frame before the browser's first real paint | Chrome 117+, Safari 17.5+, Firefox 129+ | | reflow | Mounts at data-state="closed" for one frame, then flips to "opening" on the next frame — a genuine state change the browser transitions between | No modern-CSS requirement; works everywhere | | auto (default) | Picks starting-style when the browser supports it, reflow otherwise | — |

The default theme's CSS (@slithy/modal-css/css) is written to work correctly under either strategy — see the comments in default.css if you're writing your own transitions and want the same dual support.


Mobile bottom sheet

The same responsive pattern documented in @slithy/modal-spring's README — a centered dialog on desktop, a full-height sheet sliding up from the bottom on mobile — done entirely in CSS instead of a JS animation-config prop. That's the actual value of a CSS-transition adapter: no contentTransitions-style prop exists here, because plain media queries already do the job.

The one wrinkle: the animated node (ModalContent, where data-state lives) is the dialog's parent, not the dialog itself, so a per-modal override needs to reach from the dialog (which carries data-modalid) up to it. :has() does that:

import { useBreakpoint } from "@slithy/utils";
import { Modal, useModalState } from "@slithy/modal-css";

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"
      disableOpacityTransition={!breakpointMD}
    >
      <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;
          }
        }

        /* Bottom-sheet entrance override — reaches from the content wrapper
           (where transform/data-state live) down to this dialog via :has(),
           scoped to this modal instance by data-modalid. */
        @media (max-width: 767px) {
          [data-slithy="modal-content"][data-slithy-adapter="css"]:has(
              > [data-modalid="${modalId}"]
            ) {
            @starting-style {
              transform: translateY(100%);
            }
          }

          [data-slithy="modal-content"][data-slithy-adapter="css"]:has(
              > [data-modalid="${modalId}"]
            )[data-state="closed"],
          [data-slithy="modal-content"][data-slithy-adapter="css"]:has(
              > [data-modalid="${modalId}"]
            )[data-state="closing"] {
            transform: translateY(100%);
          }
        }
      `}</style>
      {children}
    </Modal>
  );
}

Notes:

  • Include both the @starting-style block and the [data-state] rule — one covers each of the two entrance strategies, and only providing one leaves the other strategy's browsers with the default theme's smaller slide instead of the full off-screen one.
  • translateY(100%) moves the sheet by its own height, not the viewport's — it clears fully off-screen regardless of how tall the content is, and needs no viewport-unit math.
  • disableOpacityTransition is set alongside the mobile override for the same reason as the modal-spring example: a sheet sliding fully off-screen doesn't need an opacity fade layered on top.
  • height: 100dvh (not 100vh) tracks the browser's dynamic viewport, so the sheet fills the actual visible area even as a mobile browser's own toolbar hides/shows on scroll. Give the dialog an explicit, definite height at whichever breakpoint needs internal scrolling — a percentage height on a scrolling child (see @slithy/modal-parts's Main) never resolves against an auto-height ancestor.

useCssTransitionLifecycle

The hook this package's Modal/ModalRenderer are built on. Exported for building a custom adapter around the same enter/exit machinery without modal-specific coupling — it takes a boolean plus config and nothing else, the same shape as react-spring's useTransition.

import { useCssTransitionLifecycle } from "@slithy/modal-css";

const { dataState, transitionRef } = useCssTransitionLifecycle({
  open,                     // caller-controlled presence flag
  entranceStrategy,         // 'auto' | 'starting-style' | 'reflow', default 'auto'
  skipAnimation,            // resolve each phase synchronously; reduced-motion is OR'd in automatically
  transitionTimeoutMs,      // safety net if transitionend never fires, default 500
  onEntranceComplete,       // fires once when the enter transition finishes
  onExitComplete,           // fires once when the exit transition finishes
});

dataState is 'closed' | 'opening' | 'open' | 'closing' — spread it onto your animated node as data-state={dataState}. transitionRef must be attached to that same node; its own transitionend event is what resolves each phase. When your CSS transitions multiple properties with unequal durations (e.g. opacity 150ms, transform 400ms), completion is gated on the single longest-duration declared property, not whichever fires first — so a phase doesn't resolve (and, on exit, unmount) mid-animation.


useModalState

Reads per-modal context from inside a modal component. Re-exported from @slithy/modal-kit for convenience.

import { useModalState } from "@slithy/modal-css";

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-css 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. 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 completes (or immediately with skipAnimation/reduced motion) — a valid APG option. Configurable via initialFocusRef, same as modal-kit | | 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 transition 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. A covered modal shows a static, unanimated veil — no transition, since a covered/uncovered flip isn't a value worth animating | | 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 and backdrop both become immediate |


Exports

| Export | Description | | --- | --- | | Modal | CSS-transition modal component | | ModalProps | — | | ModalRenderer | Renders all open modals with a transitioning backdrop | | ModalRendererProps | — | | ModalBackdrop | Transitioning backdrop (used by ModalRenderer) | | ModalBackdropProps | — | | ModalVeil | Re-export from @slithy/modal-kit — overlay that dims a backgrounded modal when another opens in front of it. Unanimated; modal-css has no adapter-specific veil | | ModalVeilProps | — | | useCssTransitionLifecycle | The enter/exit lifecycle hook Modal/ModalRenderer are built on — see above | | UseCssTransitionLifecycleOptions | — | | UseCssTransitionLifecycleResult | — | | EntranceStrategy | 'auto' \| 'starting-style' \| 'reflow' | | CssTransitionDataState | 'closed' \| 'opening' \| 'open' \| 'closing' | | useModalState | Per-modal context hook — re-exported from modal-kit | | useModalStore | Re-export from @slithy/modal-core | | 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 |