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

sheet-view

v0.8.0

Published

Headless bottom-sheet / modal built on native <dialog> + CSS scroll-snap. Framework-agnostic core, thin React adapter, optional default theme.

Readme

sheet-view

npm version min+gzip license

A headless bottom-sheet / modal built out of parts the browser already has. Native <dialog>.showModal() gives modality, the focus trap, Escape and focus restore. CSS scroll-snap gives the drag-to-close gesture. dvh handles iOS keyboard sizing. A framework-agnostic core owns the DOM and the lifecycle. A thin React adapter portals your content into it.

  • Truly headless — ship the small required structural stylesheet, add the optional default theme, or bring your own CSS. Style it with CSS custom properties.
  • Framework-agnostic core — the vanilla core imports no React at all. The React adapter lives at sheet-view/react.
  • Native a11y for free — showModal() gives you the focus trap, the inert background, Escape handling and focus restore.
  • Tiny — 8.4 kB min+gzip for the core, 10.1 kB with the React adapter, and no runtime dependencies.
  • TypeScript-first — types ship in the box. ESM only.

Install

npm i sheet-view

React is an optional peer dependency. Only sheet-view/react needs it.

Quick start (React)

import {sheets, SheetHost} from 'sheet-view/react'
import 'sheet-view/base.css' // REQUIRED structural styles
import 'sheet-view/theme.css' // OPTIONAL default theme

// Mount once at the app root, inside your providers:
function App() {
  return (
    <>
      <YourApp />
      <SheetHost />
    </>
  )
}

// Open a sheet from anywhere — no hooks, no context:
function openSettings() {
  sheets.open({
    title: 'Settings',
    size: 'md',
    content: ({close}) => <SettingsForm onDone={close} />,
  })
}

Quick start (vanilla, no framework)

import {createSheetCore} from 'sheet-view'
import 'sheet-view/base.css'
import 'sheet-view/theme.css'

const sheets = createSheetCore()

sheets.open({
  title: 'Hello',
  size: 'sm',
  content: () => {
    const el = document.createElement('div')
    el.textContent = 'A plain-DOM sheet body.'
    return el
  },
})

A slot takes a Node, a string, or a (ctx) => Node | string function. Run pnpm docs:dev for live demos of the core and the React adapter.

No bundler (plain HTML, CDN)

dist/sheet-view.min.js is the core in one self-contained file: no imports to resolve, nothing to configure. Point a <script type="module"> at it, from a CDN or straight out of node_modules/.

<!-- REQUIRED: structure and motion -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sheet-view/dist/base.css" />
<!-- OPTIONAL: the default skin -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sheet-view/dist/theme.css" />

<script type="module">
  import {sheetCore} from 'https://cdn.jsdelivr.net/npm/sheet-view/dist/sheet-view.min.js'

  sheetCore.open({title: 'Hello', size: 'sm', content: () => 'A plain-DOM sheet body.'})
</script>

Serving your own copy is the same file by another path:

<script type="module">
  import {sheetCore} from './node_modules/sheet-view/dist/sheet-view.min.js'
</script>

Pin a version for anything real: sheet-view@<version>/dist/sheet-view.min.js. dist/sheet-view.js is the same build unminified.

Load the library one way only. The standalone file next to a bundled import is two copies with two independent sheet stacks.

The standalone carries the core alone: sheetCore and createSheetCore. dist/index.js and dist/testing.js also load unbundled, in Node, Deno, Bun and edge runtimes as well as a browser. sheet-view/react is the exception. It imports bare react, react-dom and react/jsx-runtime, which over a CDN needs an import map:

<script type="importmap">
  {
    "imports": {
      "react": "https://esm.sh/react@19",
      "react-dom": "https://esm.sh/react-dom@19",
      "react/jsx-runtime": "https://esm.sh/react@19/jsx-runtime"
    }
  }
</script>

CSS: required base + optional theme

Positioning and the scroll-snap container need real CSS to work, so the styles come in two parts:

| Import | Required? | What it is | | ----------------------- | --------- | ---------------------------------------------------------------------- | | sheet-view/base.css | Yes | Structure and motion: layout, scroll-snap, sizing, desktop centering, the entrance and exit animations. | | sheet-view/theme.css | No | The default skin: surface, radius, shadow, backdrop colour, handle, and so on. | | sheet-view/styles.css | — | Convenience: base.css + theme.css in one import. |

base.css is unlayered on purpose. That way a stray utility class in your app cannot override the rules the gesture depends on. theme.css sits inside @layer sheet-view, so your own styles always win over it.

Motion is in base.css, not in the theme. It is a mechanism, not a skin. The open path never animates the scroll, because iOS Safari will not animate scrollTo() inside a mandatory-snap scroller. So the CSS keyframes are the entrance, and a sheet with no theme still slides in correctly. The durations are tokens, so you can retune the motion or turn it off without a specificity fight: --sheet-enter-duration: 0s.

Next.js Pages Router: global CSS may only be imported from pages/_app.js — import base.css/theme.css there. The App Router allows importing them anywhere.

Theming with tokens

theme.css reads --sheet-* custom properties, each with a sensible fallback. Set any of them on :root, on [data-sheet-part='root'], or on any ancestor to restyle the sheet without touching the file. Your values apply in both light and dark:

:root {
  --sheet-surface: #14121c; /* card background */
  --sheet-text: #ffffff; /* text colour */
  --sheet-radius: 24px; /* mobile card top radius */
  --sheet-backdrop: rgb(0 0 0 / 0.6); /* dim colour */
}
  • Colours: --sheet-surface, --sheet-text, --sheet-handle, --sheet-border, --sheet-border-subtle, --sheet-hover, --sheet-backdrop
  • Geometry: --sheet-radius, --sheet-radius-desktop, --sheet-shadow, --sheet-shadow-mobile, --sheet-backdrop-blur
  • Sizing (from base.css): --sheet-width-sm|md|lg|xl (400/560/800/1000px, desktop), --sheet-height-sm|md|lg|xl (auto/65dvh/…, mobile), --sheet-inset (40px) and --sheet-inset-desktop (64px), which are the gap kept between the card and the viewport edge, and --sheet-header-gap (16px). There are also --sheet-width and --sheet-height with no suffix. They override every bucket at once, and they are the way to set an arbitrary size. See Sizing.
  • Skin: --sheet-title-size, --sheet-title-weight, --sheet-close-size, --sheet-close-radius, --sheet-handle-radius, --sheet-handle-opacity, --sheet-header-padding
  • Motion (from base.css): --sheet-enter-duration (507ms), --sheet-enter-duration-focus (75 % of it — the shorter focusOnOpen rise), --sheet-exit-duration (517ms), and three easing tokens, --sheet-enter-easing, --sheet-exit-easing and --sheet-flick-easing. Each easing token is a cubic-bezier by default. Every animation is two keyframes joined by one of those curves. They are cubic-bezier rather than a linear() stop list because WebKit does not run linear() on the compositor. One entrance or exit drives the card, the dims and the stacked sheets below on the same duration and the same curve.

To style one instance, pass className or style to open(). style sets tokens on the root, so it reaches every part — including the backdrop, which a card class cannot reach:

sheets.open({
  title: 'Filters',
  className: 'promo',
  style: {'--sheet-surface': '#14121c', '--sheet-backdrop': 'rgb(0 0 0 / 0.7)'},
})

Light & dark

The default skin follows the host page's color-scheme, not the OS. The sheet inherits color-scheme from your root and picks its palette from that:

  • Set color-scheme: dark (or light dark) on :root and the sheet renders dark, in step with your page and its native form controls.
  • A page that sets nothing (or color-scheme: light) gets a light sheet, even on a device set to dark.
  • Your own --sheet-* values always win, in either scheme.

Styling hooks

Every part of the sheet DOM carries a stable attribute you can target from your own CSS:

  • [data-sheet-part="root|backdrop|scroll|spacer|panel|card|scrim|handle|header|content|footer|overlay|anchor-layer|toplayer|viewport-layer|default-header|icon|title|close|close-icon"]
  • [data-sheet-state="opening|open|closing"] on the root
  • [data-sheet-size="sm|md|lg|xl"] on the card
  • [data-sheet-focus-open] on the root — present when the sheet was opened with focusOnOpen
  • [data-sheet-settled] on the root — added once the entrance is over and the drag is live. That is the moment the dim stops transitioning and starts following the finger.
  • matching .sv-sheet__* classes, if you prefer class selectors

The docs have an anatomy demo that colour-codes each of these parts. Run pnpm docs:dev to see it.

Stability. Four surfaces are semver-stable: the open() props, the public --sheet-* tokens, the data-sheet-* attributes, and the slot nodes. Internal --_sheet-* tokens and the DOM depth between slots may change in any release.

API

sheets.open(props) → handle

| Prop | Type | Notes | | ------------------------------- | ------------------------------------------ | ------------------------------------------------------------ | | key | string | Dedupe scope for a singleton sheet. | | strategy | 'reuse' \| 'replace' \| 'update' | Only used with key. Default 'reuse'. | | title | string | Text for the default header (ignored if headerSlot is set). Leave it out and there is no default header at all — and so no close button. | | icon | ReactNode \| (ctx) => ReactNode | Glyph in front of the title. Needs title; ignored with headerSlot. It is not aria-hidden, so mark a decorative icon yourself. | | size | 'sm' \| 'md' \| 'lg' \| 'xl' | Default 'lg'. The width and height for each bucket are tokens — see Sizing. | | focusOnOpen | boolean | A field takes focus on open, so the sheet opens keyboard-safe on mobile. | | content / headerSlot / footer / overlaySlot | ReactNode \| (ctx) => ReactNode | Slot content. (Node \| string \| fn in the core.) | | closeDisabled | boolean | Blocks X, backdrop, drag and Escape; fires onCloseAttempt instead. Chromium closes natively on a second consecutive Escape, see the notes. | | closeHidden | boolean | Leaves the default header's close button out. | | closeLabel | string | Accessible label for the close button. Default 'Close'. | | closeIcon | ReactNode \| (ctx) => ReactNode | Glyph inside the close button, in place of ×. The button itself stays ours, so you keep the label, aria-disabled and the 44×44 hit target. Needs title; ignored with headerSlot or closeHidden. | | ariaLabel | string | Accessible name. Without it, title names the dialog — through aria-labelledby for the default header, or as aria-label when headerSlot owns the row. | | cardClassName | string | Extra classes on the card. | | className | string | Class(es) on the root dialog. | | style | Record<string, string> | Inline styles and tokens on the root — reaches every part. | | onClose / onCloseAttempt / onExited | () => void | Lifecycle callbacks. |

In React, open() returns {id, close(), update(nextProps)}. The core's open() returns those three plus slots, layers, phase() and onPhase(), which are what an external renderer mounts into.

close() closes the sheet even when closeDisabled is set — that is the programmatic override. update() merges in new props and re-applies them: slots, size, cardClassName and the accessible name.

Keyed strategies: reuse returns the existing handle and does nothing else; replace closes the old sheet and opens a fresh one; update merges the props into the live sheet.

Also on sheets

  • sheets.closeAll() — starts closing every open sheet. On the core too.
  • sheets.hasLocked() — true while any open sheet has closeDisabled. Handy for a beforeunload guard. React adapter only.

<SheetHost instance?={sheets} onSlotError?={fn} />

Mount it once at the app root. It portals React slot content into the core's DOM. If a slot throws, the damage stops there: that slot renders nothing and logs to console.error, while the sheet and your app stay mounted. onSlotError(error, info, slot) is a reporting hook for Sentry. It is not a place to render a fallback.

Popovers — <SheetPortal>, useSheetLayout(), useSheetPortalTarget()

For dropdowns, select menus and pickers anchored to a trigger inside a sheet. <SheetPortal> mounts them somewhere they are not clipped, where they ride the card, and where a click does not dismiss the sheet. useSheetLayout() hands you the nodes to measure and clip against. The library ships no positioning code, so your floating-ui / Popper / Radix code keeps working.

<SheetPortal>
  <div style={{position: 'absolute', top, left}}>…</div>
</SheetPortal>

<SheetPortal layer="viewport">   {/* toasts: above the card, viewport-fixed */}
  <Toast />
</SheetPortal>

For the full contract, the positioning rules and the paint-order guarantee, see Popovers.

Multiple instances

createSheetCore(options?) builds an isolated core. createSheets(core?) wraps one for React, so to tune a React instance you pass a core you made yourself:

const mySheets = createSheets(createSheetCore({closeMs: 300}))

<SheetHost instance={mySheets} />, <SheetPortal instance={mySheets}> and useSheetPortalTarget({instance: mySheets}) bind to a specific instance. The last two only matter outside a slot, because inside one the sheet is already known from context.

Core options: closeMs, dragCloseMs, enterMs, openSettleMs, breakpoint, zoomLock, closeLabel.

closeMs and dragCloseMs (both 517) cover the whole exit. The card's animation, the promotion of the sheet underneath, and the delay before the DOM is removed all read the one number, so nothing can drift out of step.

enterMs is the JS-side mirror of --sheet-enter-duration. It retunes the mobile entrance and the default for openSettleMs (when the drag arms) from one number, so the card and the dim cannot drift apart. A CSS override of the public token still wins over it. Left unset, both fall back to 507.

breakpoint (default 768) is the width below which a sheet opens in mobile mode.

zoomLock (default false) pins maximum-scale=1 while a sheet is open. Leave it off: disabling zoom is a WCAG 1.4.4 failure, and the base theme already stops iOS focus-zoom by keeping sheet inputs at 16 px or larger.

Testing

jsdom ships HTMLDialogElement without showModal(), show() or close(), so any test that opens a sheet throws showModal is not a function. Install the shim once, in your setup file:

// vitest.setup.js / jest.setup.js
import {installDialogShim} from 'sheet-view/testing'

installDialogShim()

You can call it more than once safely. It checks each member on its own, and it does nothing at all in a real browser — so the same setup file works under vitest browser mode or Playwright.

Some things it does not emulate, on purpose: the top layer, focus trapping, inert, focus restore and requestClose(). jsdom cannot host those, and faking them gives you tests that pass against a fiction. Escape is not translated either. Dispatch cancel yourself, which is what the browser actually does:

fireEvent(dialog, new Event('cancel', {cancelable: true}))

If you would rather press Escape in tests, opt in with installDialogShim({cancelOnEscape: true}). It treats the last dialog[open] as the topmost one, which is only an approximation — jsdom has no top-layer stack.

Don't hand-roll this. A shim that patches showModal() but not close() looks fine until the sheet closes. The core releases its scroll lock on the native close event, so without it the page stays frozen for the rest of the test file.

Notes & known limitations

  • open() is client-only. open() touches document, so call it in the browser, not during SSR. <SheetHost> itself is SSR-safe and renders nothing on the server.

  • Drag-to-dismiss starts from the header or the grabber. The content area uses overscroll-behavior: contain, so scrolling a long body never dismisses the sheet. That is deliberate — a long read should not end in an accidental close.

  • iOS keyboard and 100dvh. dvh does not shrink when the keyboard appears, so a pinned footer can end up behind it while a field has focus. focusOnOpen fixes the moment of opening. For a footer that follows the keyboard, drive it from visualViewport.

  • Trigger-button :hover on iOS. iOS applies :hover on tap and keeps it until you tap another element. So a swipe-closed sheet leaves the button that opened it highlighted, and tapping empty space does not clear it. Put your own :hover styles behind @media (hover: hover); the built-in theme already does.

  • Password-manager autofill. An extension that shows its dropdown as a popover="manual" element straight on <body> or <html> (iCloud Passwords in Chrome does) gets that element moved into the topmost sheet with moveBefore(). It stays open, in place and focused. When that sheet closes, it moves to the sheet below, or back to where it was injected.

    • A password manager that puts its menu inside the dialog itself, as Bitwarden and Proton Pass do, is left alone.
    • Nodes your page renders are left alone. Only a node with inline !important styles and no framework bookkeeping on it qualifies. A hand-built popover of exactly that shape, shown on <body> over a sheet, would be moved too, and put back when the sheet closes.
    • Once moved, the dropdown is part of the sheet, so Tab from the last field can reach it.

    Your own popovers are DOM you control: mount them with <SheetPortal> and they paint above the card, stay clickable, and do not dismiss the sheet. See Popovers.

  • strategy: 'replace'. The replaced sheet closes silently: onExited fires, onClose does not. A native close — a <form method="dialog"> submit, or a browser force-close — tears down cleanly and fires both.

  • closeDisabled and Escape. Escape closing a modal dialog is the platform contract, and closeDisabled opts out of it by cancelling the dialog's cancel event. Chromium honours that cancel only while the page has user activation, and an Escape keypress grants none, so a second Escape with no click or other key in between closes the dialog natively. The sheet detects the native close and tears down at once, firing onClose and onExited, with no exit animation. X, backdrop and drag stay blocked for as long as the flag is set.

  • The raw top layer is pointer-events: none. You only reach it by going around the mount points and appending to slots.toplayer yourself. Children there must set pointer-events: auto on the panel itself. A full-bleed wrapper that does it instead swallows backdrop-dismiss and drag-to-close. <SheetPortal layer="viewport"> and layers.viewport handle this for you. (overlaySlot is not affected: it is display: contents, so its children are interactive as they are.)

  • A slot that throws is contained to that slot. It renders nothing and logs to console.error. The sheet and the rest of your app stay mounted, so the default header's close button still works. That slot stays blank until the sheet closes — there is no retry. Pass onSlotError to <SheetHost> to forward it to Sentry.

  • Crossing the breakpoint. The mobile/desktop decision is made once, at open(). A sheet stays visible through a rotate or a split-view change, but the drag gesture only binds on open. A sheet opened on desktop and then narrowed is not draggable until you reopen it.

  • Scrollbar compensation. While a sheet is open, it reserves the width of the classic scrollbar as body padding, so the page does not shift. If you would rather not reserve that gap, set scrollbar-gutter: stable on html. The gap measures as zero and nothing is added.

Browser support

All modern browsers with <dialog>.showModal() and CSS scroll-snap.