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

glidesheet

v0.5.4

Published

A performant React bottom sheet with reliable scroll-vs-drag detection

Downloads

1,358

Readme


Why GlideSheet?

Bottom sheets on the web don't feel native. The core challenge is the scroll-to-drag transition — when scrollable content reaches the top and you keep pulling down, the sheet should follow your finger and slide down. Most libraries fail at this, resulting in rubber-band bouncing, jerky transitions, or the sheet ignoring the gesture entirely.

Other common pain points with existing solutions:

  • Heavy dependencies that conflict with your UI library
  • pointer-events: none leaking to the body in non-modal mode
  • position: fixed hacks on the body that break iOS layouts
  • Unreliable animation callbacks that fire too early or not at all

GlideSheet was built to solve these problems. It focuses on one direction (bottom), ships zero runtime dependencies, and makes the scroll-to-drag transition feel native.

Features

  • Reliable scroll-to-drag — When scrollable content hits the top, the sheet follows your finger
  • Snap points — Fraction (0-1) or pixel values ("185px")
  • Sequential snapping — Optional snapToSequentialPoint for step-by-step navigation
  • Controlled & uncontrolled — Works both ways
  • Modal & non-modal — No pointer-events: none leak in non-modal mode
  • Nested sheets — Stacking with scale effect
  • Floating mode — Detached from edges with rounded corners
  • Floating bar — Element that hovers above the sheet and fades on expand
  • Footer — Non-scrollable slot pinned at the bottom for action buttons
  • Progressive overlay — Overlay opacity follows drag progress
  • Focus trap — Modal only, with focus restore on close
  • ESC to close — Built-in keyboard support
  • iOS keyboard handling — Via visualViewport API, no position: fixed hack
  • onAnimationEnd — Fires reliably via transitionend (not setTimeout)
  • useBottomSheet() hook — Access state from any child component
  • Zero dependencies — Only React as peer dependency
  • React 18 + 19 compatible
  • ~9KB gzipped bundle (JS + CSS)

Installation

npm install glidesheet
# or
pnpm add glidesheet
# or
bun add glidesheet

Quick Start

import { BottomSheet } from 'glidesheet';
import 'glidesheet/style.css';

function App() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>

      <BottomSheet.Root open={open} onOpenChange={setOpen}>
        <BottomSheet.Portal>
          <BottomSheet.Overlay />
          <BottomSheet.Content className="my-sheet">
            <BottomSheet.Handle />
            <BottomSheet.Title className="sr-only">My Sheet</BottomSheet.Title>
            <h2>Hello from GlideSheet</h2>
            <p>Drag me down to close.</p>
          </BottomSheet.Content>
        </BottomSheet.Portal>
      </BottomSheet.Root>
    </>
  );
}

With Snap Points

<BottomSheet.Root
  open={open}
  onOpenChange={setOpen}
  snapPoints={[0, '200px', 0.5, 1]}
  activeSnapPoint={snap}
  onActiveSnapPointChange={setSnap}
  snapToSequentialPoint
>
  <BottomSheet.Portal>
    <BottomSheet.Content className="my-sheet">
      <BottomSheet.Handle />
      <BottomSheet.Title className="sr-only">Snap Points</BottomSheet.Title>
      {/* Your content */}
    </BottomSheet.Content>
  </BottomSheet.Portal>
</BottomSheet.Root>

Snap point values:

  • Numbers 0-1 — Fraction of viewport height (0.5 = 50%)
  • Strings — Pixel values ("200px" = 200px from bottom)
  • 0 — Closed position

Scrollable Content

GlideSheet handles the scroll-to-drag transition automatically. When your content is scrollable and the user scrolls to the top, pulling down will drag the sheet instead of bouncing the scroll.

<BottomSheet.Content className="my-sheet">
  <BottomSheet.Handle />
  <div style={{ overflowY: 'auto', flex: 1 }}>
    {/* Long scrollable content */}
    {items.map(item => (
      <div key={item.id}>{item.name}</div>
    ))}
  </div>
</BottomSheet.Content>

How scroll-vs-drag works

GlideSheet uses a two-layer strategy to distinguish between scrolling content and dragging the sheet, working consistently across iOS, Android, and desktop:

  1. Touch layer — A touchmove listener on Content decides whether to let the browser scroll natively or to preventDefault so pointer events can drive the sheet drag:

    • Non-scrollable areas (handle, header, title, footer): always prevents default — the sheet follows your finger.
    • Scrollable areas at scrollTop=0 pulling down: prevents default — the sheet drags down instead of rubber-banding.
    • Scrollable areas mid-scroll: lets the browser scroll natively.
    • Guards e.cancelable before calling preventDefault() to avoid [Intervention] warnings when the browser has already taken control.
  2. Pointer layer — The shouldDrag() function in onDrag walks up the DOM from the touch target to decide if the sheet should move. If the target is inside a scrollable element that isn't scrolled to the top, drag is blocked.

To opt-out specific elements from drag:

<div data-glidesheet-no-drag>
  This area won't trigger sheet drag
</div>

Floating Mode

<BottomSheet.Root open={open} onOpenChange={setOpen} floating>
  <BottomSheet.Portal>
    <BottomSheet.Content className="my-floating-sheet">
      <BottomSheet.Handle />
      {/* Content */}
    </BottomSheet.Content>
  </BottomSheet.Portal>
</BottomSheet.Root>

Customize the floating border radius:

:root {
  --glidesheet-floating-radius: 1.5rem;
}

useBottomSheet Hook

Access sheet state from any child component:

import { useBottomSheet } from 'glidesheet';

function MyComponent() {
  const {
    isOpen,
    isDragging,
    isFullyOpen,
    isMinimized,
    isClosed,
    activeSnapPoint,
    activeSnapPointIndex,
    close,
    open,
    snapTo,
  } = useBottomSheet();

  return (
    <button onClick={() => snapTo(1)}>Expand</button>
  );
}

Non-Modal Mode

In non-modal mode, the page behind the sheet remains interactive. No overlay, no focus trap, no pointer-events: none on body.

<BottomSheet.Root open={open} onOpenChange={setOpen} modal={false}>
  <BottomSheet.Portal>
    <BottomSheet.Content className="my-sheet">
      <BottomSheet.Handle />
      {/* Content */}
    </BottomSheet.Content>
  </BottomSheet.Portal>
</BottomSheet.Root>

Nested Sheets

<BottomSheet.Root open={parentOpen} onOpenChange={setParentOpen}>
  <BottomSheet.Portal>
    <BottomSheet.Content>
      <BottomSheet.Handle />
      <p>Parent sheet</p>

      <BottomSheet.NestedRoot open={childOpen} onOpenChange={setChildOpen}>
        <BottomSheet.Portal>
          <BottomSheet.Content>
            <BottomSheet.Handle />
            <p>Child sheet</p>
          </BottomSheet.Content>
        </BottomSheet.Portal>
      </BottomSheet.NestedRoot>
    </BottomSheet.Content>
  </BottomSheet.Portal>
</BottomSheet.Root>

API Reference

<BottomSheet.Root>

| Prop | Type | Default | Description | |------|------|---------|-------------| | open | boolean | — | Controlled open state | | defaultOpen | boolean | false | Uncontrolled initial state | | onOpenChange | (open: boolean) => void | — | Called when open state changes | | snapPoints | (number \| string)[] | — | Snap point positions | | activeSnapPoint | number \| string \| null | — | Controlled active snap point | | onActiveSnapPointChange | (snap: number \| string \| null) => void | — | Called when snap point changes | | snapToSequentialPoint | boolean | false | Only snap to adjacent points | | fadeFromIndex | number | last snap | Index from which overlay fades | | modal | boolean | true | Enable overlay, focus trap, body lock | | dismissible | boolean | true | Allow close by drag/ESC/overlay click | | handleOnly | boolean | false | Only allow drag from Handle | | floating | boolean | false | Detached floating mode | | closeThreshold | number | 0.25 | Fraction of height to trigger close | | nested | boolean | false | Internal — set by NestedRoot | | noBodyStyles | boolean | false | Skip body style modifications | | container | HTMLElement \| null | document.body | Portal container | | onDrag | (event, percentageDragged) => void | — | Called during drag | | onDragStart | () => void | — | Called when drag starts | | onDragEnd | () => void | — | Called when drag ends | | onRelease | (event, open) => void | — | Called on pointer release | | onAnimationEnd | (open: boolean) => void | — | Called after open/close animation | | onClose | () => void | — | Called when sheet closes | | progressiveOverlay | boolean | false | Overlay opacity follows drag progress | | progressiveOverlayMaxOpacity | number | 0.35 | Max overlay opacity in progressive mode |

<BottomSheet.Content>

Renders a <div> with role="dialog". Accepts all HTML div props. The sheet's visual appearance (background, border-radius, shadow, size) is fully controlled by your className/style.

<BottomSheet.Overlay>

Semi-transparent backdrop. Only renders in modal mode. Click to close.

<BottomSheet.Handle>

Draggable handle bar with tap-to-cycle snap points. Accepts preventCycle prop and custom children.

<BottomSheet.Portal>

Renders children into a portal. Accepts forceMount to keep mounted when closed. Accepts container to override the target.

<BottomSheet.Trigger>

Button that opens the sheet. Sets aria-haspopup="dialog" and aria-expanded.

<BottomSheet.Close>

Button that closes the sheet.

<BottomSheet.Title>

Accessible title (<h2>). Linked to Content via aria-labelledby.

<BottomSheet.Description>

Accessible description (<p>). Linked to Content via aria-describedby.

<BottomSheet.Footer>

Non-scrollable area at the bottom of the sheet. Stays visible while content scrolls — ideal for action buttons.

<BottomSheet.Content>
  <BottomSheet.Handle />
  <div style={{ overflowY: 'auto', flex: 1 }}>
    {/* Scrollable content */}
  </div>
  <BottomSheet.Footer>
    <button>Confirm</button>
  </BottomSheet.Footer>
</BottomSheet.Content>

<BottomSheet.FloatingBar>

A floating element that hovers above the sheet and fades out as the sheet expands. Useful for navigation pills, page indicators, or contextual actions.

<BottomSheet.Root open={open} onOpenChange={setOpen}>
  <BottomSheet.Portal>
    <BottomSheet.Overlay />
    <BottomSheet.FloatingBar className="my-pill">
      <span>3 items selected</span>
    </BottomSheet.FloatingBar>
    <BottomSheet.Content className="my-sheet">
      <BottomSheet.Handle />
      {/* Content */}
    </BottomSheet.Content>
  </BottomSheet.Portal>
</BottomSheet.Root>

| Prop | Type | Default | Description | |------|------|---------|-------------| | gap | number | 16 | Distance (px) between bar and sheet top | | hideWhileDragging | boolean | false | Hide bar during drag | | fadeStartPercent | number | 50 | Sheet height % where fade begins | | fadeEndPercent | number | 65 | Sheet height % where bar is fully hidden |

<BottomSheet.NestedRoot>

Same API as Root, for nested sheets. Automatically handles parent stacking effect.

CSS Custom Properties

:root {
  --glidesheet-z-index: 50;              /* Sheet z-index */
  --glidesheet-floating-radius: 1rem;    /* Floating mode border radius */
  --glidesheet-handle-radius: 1.5rem;    /* Handle top border radius */
  --glidesheet-handle-bg: inherit;       /* Handle background */
  --glidesheet-handle-pill-bg: rgba(0, 0, 0, 0.25); /* Handle pill color */
}

Data Attributes

Use these for custom styling:

| Attribute | Values | Element | |-----------|--------|---------| | data-glidesheet | — | Content | | data-state | "open" | "closed" | Content, Overlay | | data-snap-points | "true" | "false" | Content, Overlay | | data-floating | "true" | "false" | Content | | data-glidesheet-overlay | — | Overlay | | data-glidesheet-handle | — | Handle | | data-glidesheet-footer | — | Footer | | data-glidesheet-floating-bar | — | FloatingBar | | data-glidesheet-no-drag | — | Any child (opt-out from drag) |

Comparison

| | GlideSheet | Vaul | react-modal-sheet | react-spring-bottom-sheet | |---|---|---|---|---| | Bundle | ~9KB gzip | ~77KB | ~30KB + Motion | ~25KB + react-spring | | Dependencies | 0 | Radix Dialog | Motion | react-spring, react-use-gesture | | Scroll-to-drag | Native feel | Inconsistent | Manual config | Manual config | | Snap points | Fraction + px | Fraction + px | 0-1 range | Callback-based | | Non-modal | Clean (no side effects) | pointer-events leak | Not built-in | blocking={false} | | Keyboard (iOS) | visualViewport API | position: fixed hack | avoidKeyboard prop | — | | Nested sheets | Built-in stacking | Built-in | — | — | | Floating mode | Built-in | — | — | — | | React 18 + 19 | Yes | Yes | Yes | No (react-spring) | | Compound components | Yes | Yes (Radix-style) | Yes | No (single component) |

AI Agent Skill

GlideSheet ships an Agent Skill so AI coding agents (Claude Code, Cursor, Copilot, Gemini CLI, etc.) understand the API and patterns out of the box.

npx skills add imri-engineer/glidesheet

Development

bun install
bun test          # Run tests
bun run build     # Build for production
bun run lint      # TypeScript check

License

MIT