Skip to content

Add swipe-to-dismiss gesture support for Drawer, Sheet, Sidebar#124

Draft
developit wants to merge 21 commits into
mainfrom
claude/css-swipeable-drawer-xEArB
Draft

Add swipe-to-dismiss gesture support for Drawer, Sheet, Sidebar#124
developit wants to merge 21 commits into
mainfrom
claude/css-swipeable-drawer-xEArB

Conversation

@developit

Copy link
Copy Markdown
Owner

Adds native swipe-to-dismiss functionality to Drawer, Sheet, and Sidebar components on touch devices, while preserving the existing transform-based animations on desktop.

Summary

On touch devices (hover: none and pointer: coarse), the overlay dialogs now become CSS scroll-snap containers where users can swipe the panel away into an empty "rail" to dismiss. The gesture tracking, momentum, and snap-back are all handled by the browser's native scroll behavior. A new installSwipe() helper provides minimal JavaScript control, only nudging the scroller at open/close boundaries.

Key Changes

  • New installSwipe() handler (src/lib/commands.ts): Registers beforetoggle and scrollend listeners that:

    • On open: jumps to the rail edge instantly, then smooth-scrolls the panel into view
    • On scroll settle: closes the dialog if the panel has been flung off-screen
    • Tracks "entering" dialogs to ignore their own entrance scroll as a dismiss
  • Shared swipe CSS primitive (src/components/_swipe.css): Provides the scroll-snap container scaffolding for Drawer and Sheet, configurable via CSS variables (--swipe-axis, --swipe-snap, --swipe-align)

  • Component updates: Drawer, Sheet, and Sidebar now wrap their content in *-panel and *-rail divs. On desktop these are inert (display: none or display: contents); on touch they become the scroll-snap structure

  • Rail tap-to-close: Added click handler in dialogsDropdownsClickHandler to close dialogs when tapping the rail area

  • Tests and docs: Added comprehensive test suite for the swipe control plane and updated component documentation

Implementation Details

  • The solution uses CSS media queries to opt dialogs in via --swipe: 1, enabling zero JavaScript branching in components
  • Desktop behavior is completely unchanged—existing transform animations remain the default
  • The entering WeakSet prevents the entrance scroll from being misinterpreted as a dismiss gesture
  • All gesture physics (momentum, snap-back) are delegated to the browser's scroll-snap implementation

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb

Make the overlays gesture-driven on touch with almost everything in CSS.
On `(hover: none) and (pointer: coarse)` each `<dialog>` becomes a native
scroll-snap container: the content panel rests against one edge next to an
empty "rail" you can fling it away into, so the browser owns the drag,
momentum and snap-back. Desktop is untouched (the existing transform
transition) — the mode is selected purely in CSS via a `--swipe` flag, so
there's no branching in component code.

The entire JS control plane is one shared `installSwipe()` helper (~15 lines):
it glides the panel in on open (`beforetoggle`) and closes the dialog once a
dismiss gesture settles off-screen (`scrollend`). Net JS cost for the feature
is ~0.4KB gzipped across all three components, versus the multi-KB gesture
engines JS-driven drawer libraries ship.

- Drawer/Sheet/Sidebar render a `rail` + `panel` wrap; on desktop the panel is
  `display: contents` and the rail collapses, so layout is identical to before.
- Add a shared `_swipe.css` partial parameterising axis/edge per component.
- Convert Sidebar from `createSimpleComponent` to a plain component so it can
  carry the scaffolding; drop dead commented code along the way.
- Rail taps dismiss via the existing dialog backdrop handler.
- Tests: SSR smoke coverage for the new structure + a mocked unit test pinning
  the open/dismiss control plane. Docs + changeset included.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
@netlify

netlify Bot commented Jun 7, 2026

Copy link
Copy Markdown

Deploy Preview for kinu-sh ready!

Name Link
🔨 Latest commit 2337b19
🔍 Latest deploy log https://app.netlify.com/projects/kinu-sh/deploys/6a46510c197f7d00089276b2
😎 Deploy Preview https://deploy-preview-124--kinu-sh.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Size Change: +3.44 kB (+9.12%) 🔍

Total Size: 41.1 kB

📦 View Changed
Filename Size Change
benchmarks/size/.tmp/few-components/bundle.css 3.6 kB +572 B (+18.86%) ⚠️
benchmarks/size/.tmp/nearly-all-components/bundle.css 12 kB +1.07 kB (+9.83%) ⚠️
benchmarks/size/.tmp/nearly-all-components/bundle.js 4.36 kB +362 B (+9.05%) 🔍
dist/index.css 12 kB +1.07 kB (+9.78%) ⚠️
dist/index.js 6.71 kB +366 B (+5.77%) 🔍
ℹ️ View Unchanged
Filename Size
benchmarks/size/.tmp/few-components/bundle.js 637 B
benchmarks/size/.tmp/one-component/bundle.css 1.52 kB
benchmarks/size/.tmp/one-component/bundle.js 341 B

compressed-size-action

claude added 9 commits June 8, 2026 03:08
Completely rework the swipe implementation to use zero wrapper elements.

On touch devices the <dialog> itself becomes a CSS scroll-snap container:
a ::before pseudo acts as the transparent dismiss rail and
background-attachment:local paints the panel surface behind the content.
No rail divs, no panel wrappers — "zero wrapper divs" stays true.

This approach covers:
- Drawer (k="drawer-content")
- Popover with mobile="drawer"
- DropdownMenu with mobile="drawer"
- ContextMenu with mobile="drawer"

Sheet and Sidebar are reverted to their original transform-based transitions
(horizontal scroll-snap can't be done markup-free, and the sidebar had an
overlay-blocking bug when closed).

The JS control plane is now ~10 lines: scrollTo on open (beforetoggle),
close on dismiss (scrollend at scrollTop 0). Desktop is unchanged — the
mode is selected purely in CSS via --swipe: 1.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The swipe scroller styles (display:flex, position:fixed, inset:0) were applied
unconditionally, and author display:flex beats the UA dialog:not([open])
display:none by cascade origin. That turned every closed drawer and
mobile="drawer" overlay into a full-viewport transparent overlay that blocked
scrolling and interaction.

Gate all the layout on [open]; keep only the --swipe flag ungated so the
beforetoggle handler (which fires before [open] is set) can still detect swipe
mode. Verified on touch: closed overlays are display:none, the page stays
scrollable/interactive, and open + swipe-to-dismiss still work.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Two fixes for the swipe scroller:

1. Content was crushed to min-content height (~40px slivers). The dialog is a
   column flex container whose 100dvh rail consumes the whole fixed height, so
   default flex-shrink:1 children were squeezed to nothing instead of
   overflowing into scroll range. flex:none on children restores natural
   heights — drawers now open to their full panel, and the gesture gets a real
   scroll range (the "dead" swipe feel and missing Android overscroll bounce
   were side effects of the ~40px range; overscroll-behavior:contain was
   already computed correctly).

2. Replace the per-child margin hack with horizontal padding on the dialog
   itself, restoring the original padding contract so consumer overrides like
   the demo's `padding: 0` full-bleed drawers compose correctly. Horizontal
   padding doesn't disturb the rail or the painted surface (the background
   positioning area spans the full width). Vertical breathing room comes from
   first/last child margins, which stay inside the ::after snap anchor. The
   grab handle is dropped — it hijacked the consumer's ::first-child::before.

Also scope the demo's kh-app-maximized overrides (overflow:hidden, 100dvh-20px
height) to non-touch so they don't disable the swipe scroller.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…mation

Three fixes for the swipe drawer polish:

1. Rounded corners + drag handle: The ::before rail is extended 1.5rem past
   100dvh (with negative margin to overlap the content top), its bottom
   painted with the panel bg color and border-radius. A radial-gradient on
   the same pseudo draws a centered handle bar. No consumer pseudos are
   touched — everything is on the rail itself.

2. Exit flash: Removing [open] stripped the swipe layout, briefly revealing
   the base transform:translateY(100%) before display:none kicked in. Fix:
   set transition:none on the non-[open] swipe selector too (safe — it
   doesn't affect display or layout, just prevents the exit slide).

3. Double animation on open: The base CSS transform transition
   (translateY(100%) → 0) and the JS scrollTo({smooth}) were both firing.
   With transition:none on both the base and [open] swipe selectors, the
   scroll animation is the only entrance — no more 600ms+ double easing.
   Also removed scroll-behavior:smooth from the CSS (the JS scrollTo call
   passes behavior:'smooth' explicitly; CSS scroll-behavior was redundant
   and could slow snap-settling).

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Two fixes for the latest round of feedback:

1. Entrance animation restored — and faster. scrollTo({behavior:'smooth'}) on
   a freshly-displayed scroller was unreliable (and tied the entrance to the
   browser's slow built-in scroll easing). Now installSwipe jumps the scroller
   to its open position instantly inside the pre-paint rAF, and a one-shot
   k-swipe-in animation (translate: 0 100% → none, 300ms var(--k-ease))
   slides the whole dialog up. This is @starting-style semantics via
   @Keyframes@starting-style itself can't nest inside @media per our CSS
   tooling. Exit stays instant (transition:none), no flash.

2. The rounded cap + handle were painted on the ::before rail, which lives
   inside the dialog's content box — so with any horizontal padding the cap
   rendered 2rem narrower than the full-bleed surface (the broken "top bar"
   on the docs dropdown). All painting now moves to background layers on the
   dialog itself: two quarter-disc radial-gradients for the corners, a strip
   between them, the handle pill, and the main surface — all
   background-attachment:local, positioned in the padding box, so they span
   the full width and stay aligned regardless of consumer padding.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The panel vanished instantly on dismiss but the 300ms backdrop fade-out from
the base CSS lingered, making the close feel slow. Set transition:none on
the non-[open] ::backdrop in swipe mode — the fade-in still runs (the
[open]::backdrop rule from the base styles carries the transition property).

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The ::backdrop opacity is now driven by the swipe position itself via a named
scroll-timeline on the dialog and a scroll-driven animation on ::backdrop —
dragging the panel dims/undims the page in lockstep with the finger, and a
dismissed panel leaves no lingering dim (fully fixes the slow-feeling close).

A second, time-based instance of the same keyframes covers the 300ms entrance
slide: the scroller is already parked at its open position then, so the
scroll-driven instance alone would snap to fully dimmed. It's listed last so
it wins while it runs, then hands off seamlessly (both end at opacity 1).

Gated behind @supports (animation-timeline: scroll()); other browsers keep the
existing transition fade-in / instant-out behavior.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Move all swipe layout properties except display:flex to the non-[open] base
selector. A display:none element generates no box, so position/inset/translate
are inert when closed — but they're present when the [open] attribute is
removed, giving the exit transition a stable starting layout to animate from.

The base now carries translate:0 100% (the closed position) and a transition
on translate + display (allow-discrete) + overlay (allow-discrete). The [open]
rule sets translate:none (open position) and display:flex. On ESC or back:

1. [open] removed → translate transitions from none to 0 100% (slides down)
2. display transitions discretely — stays flex for 300ms, then none
3. The backdrop fades out via its own opacity transition (same 300ms)

Swipe dismiss is unaffected: the scroll-driven animation has already brought
the backdrop to 0 and the panel is behind the rail when close() fires, so the
translate transition is imperceptible.

The covering-page bug cannot recur because only display:flex is gated on
[open], and that was the only property whose author value beat the UA
dialog:not([open]) { display: none }.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The swipe already scrolls the panel off-screen, then close() fires — but the
CSS translate transition (meant for ESC/back) would play a second slide-down
on top of the already-gone panel. Fix: set translate to the closed position
inline before calling close(), so the transition has nowhere to go. Cleared
on the next open.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
@developit developit force-pushed the claude/css-swipeable-drawer-xEArB branch from 22ec54a to c022275 Compare June 11, 2026 12:55
claude added 11 commits June 11, 2026 13:31
The remaining exit flash on Drawer: the rail/flex structure was [open]-gated,
so during any exit transition the rail pseudo vanished and the content jumped
to the top of the transparent full-screen box — that collapsed layout is what
slid down. Fix: ungate everything except display:flex (boxes don't exist at
display:none, so closed state is unaffected). Now ESC/back slides the intact
panel down with its scroll position preserved, and the swipe exit shows only
the transparent rail.

Swipe teardown is made instant with one inline line: transitionDuration='0s'
before close(), cleared on reopen. The prettier idea — a scroll-driven
animation stepping a --k-exit custom property that feeds transition-duration,
continued across the close via animation list position matching — was
implemented and frame-tested, but doesn't work: transition parameters come
from the after-change style, which (in Chrome) does not carry
CSS-animation-derived custom property values. The inline declaration does.

Also removes the previous style.translate hack, which frame sampling proved
ineffective (both before/after-change values land in the same style update,
so the transition fired regardless).

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…ex crush

Backdrop click: a swipe overlay spans the whole viewport, so the existing
outside-the-rect backdrop check never fired, and taps on the dimmed rail hit
the dialog itself. The click handler now treats any direct dialog hit above
the panel surface (clientY < clientHeight - scrollTop) as a backdrop click —
and the close is animated for free via the exit transition.

Flex crush: the previous commit dropped [open] from the swipe children
selectors, lowering their specificity below consumer rules like the demo's
.kh-composer-list { flex: 1 } — which the rail then crushed to 8px (the
"60px tall model picker"). flex:none is a geometric invariant inside the rail
scroller (the rail consumes the entire fixed height, so flex-sizing against
the container is meaningless), so assert it with !important, matching the
reduced-motion invariants in base.css. Children still size intrinsically via
height/max-height.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Extend the swipe-to-dismiss system to the horizontal axis. Sheet (right
panel, swipe left to dismiss) and mobile Sidebar (left panel, swipe right
to dismiss) now use the same scroll-snap pattern as Drawer, but with a CSS
grid layout: two columns (rail + panel) where content children are placed in
the panel column and stack vertically via auto-rows. No wrapper divs.

The --swipe custom property now encodes axis and direction:
  y   — Drawer: vertical, dismiss at scrollTop 0
  x   — Sheet: horizontal, dismiss at scrollLeft 0
  -x  — Sidebar: horizontal, dismiss at scrollLeft max

The JS control plane (installSwipe) is axis-aware: swipeToggle jumps to the
open scroll position per axis, swipeSettle checks the dismiss condition per
axis, and the backdrop-click handler computes rail-vs-panel hit per axis.

Scroll-driven backdrop dimming extends to horizontal via scroll-timeline on
the inline axis. Entrance animations slide from the appropriate edge. Exit
transitions (ESC/back) slide back out with the same 300ms timing.

Key fix: scrollTo({behavior:'instant'}) didn't survive mandatory snap in the
horizontal case (the rail snap pulled it back). Direct property assignment
(el.scrollLeft = max) works because it bypasses the smooth-scroll machinery
that re-evaluates snap points.

Back-gesture conflict mitigation: overscroll-behavior:contain on both axes
prevents the browser's edge-swipe-back from firing during panel swipes.

14 unit tests (12 swipe covering all three axes + 2 SSR).

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Sheet:
- Buttons use justify-self:start so they size naturally (matching desktop)
- ::after snap target with scroll-snap-align:end snaps at scrollLeft=max
  (the previous :last-child snap didn't reach max when the child was small)

Sidebar:
- Backdrop animation direction: use k-swipe-fade with reverse on the
  scroll-driven layer so opacity=1 at scrollLeft=0 (open) and opacity=0
  at scrollLeft=max (closed). Previous k-sidebar-fade + reverse was double-
  inverted.
- Vertical scroll: split the axes — dialog scrolls only horizontally
  (overflow-x:auto, overflow-y:hidden), content nav scrolls vertically
  (overflow-y:auto). Prevents the 2D-scroller touch-direction-locking issue.
- grid-template-rows:auto 1fr constrains the grid to viewport height,
  header auto-sizes, nav fills the rest and scrolls.
- ::before rail uses grid-row:1/-1 (spans the 2 explicit rows cleanly
  instead of span 999 creating hundreds of empty implicit rows).

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Sidebar close flicker:
- The swipe-mode `display:grid` lived only on [open]; the closed value fell
  back to the base `display:flex` (the persistent desktop sidebar). A discrete
  grid→flex display flip at the exit midpoint reflowed the panel to full width
  for one frame. Add `[k="sidebar"]:not([open]) { display: none }` in the swipe
  block so the exit animates grid→none via allow-discrete, holding the panel's
  grid layout for the whole slide-out. Specificity (0,2,0) beats the demo's
  `.docs-sidebar` class.

Scroll chaining (match iOS/Android nested-scroll sheet physics):
- ScrollArea now defaults to `overscroll-behavior: contain` (a self-contained
  region shouldn't leak scroll to the page).
- Inside a swipe overlay on touch (Drawer/Sheet/Sidebar and mobile="drawer"
  Popover/Dropdown/ContextMenu), ScrollArea switches to `overscroll-behavior:
  auto` so a fling past the list edge chains out to the overlay's scroll
  container and dismisses it. The overlay itself keeps `contain`, so it's the
  page-boundary stop — chaining goes inner → overlay, never to the page.
- Sidebar's inner scroll region and the demo's `.docs-sidebar-content` switched
  from contain to auto for the same reason.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The Sheet's touch swipe mode uses a CSS grid to place the rail and panel
without a wrapper element (a right-anchored swipe panel can't be built in
normal flow — scrollable overflow is clamped at the inline-start edge, so the
rail can't live to the left as flow content). Grid blockifies its items, which
would stretch inline-level children that are natural-width in block flow.

Extend the justify-self pin to anchors (alongside button / [k="button"]) and
add a comment explaining the grid is an internal mechanism and which block-flow
behaviors these rules restore (and the one residual: vertical margins don't
collapse between grid items).

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Previously the swipe (touch) mode used a grid while desktop used block (Sheet)
or flex (Sidebar), so user-supplied children laid out under a different
algorithm across breakpoints. Unify on grid everywhere — same stretching,
sizing and quirk-neutralization at all widths; only the grid parameters adapt
responsively (the touch mode adds the rail column).

Sheet:
- Base is now a single-column grid (`display:grid` on [open]); horizontal
  padding moved to the children so the box model matches the swipe mode.
- The child-placement + inline-element justify-self pins moved to the base so
  they apply at all breakpoints; the swipe block only adds the rail column and
  moves content to column 2.

Sidebar:
- Base switches from flex column to a stacking grid.
- Demo `.docs-sidebar` switches to `grid-template-rows: auto 1fr` (it set its
  own `display:flex` that would otherwise win on desktop) — the always-present
  4rem header row + filling, scrollable content row, matching the swipe mode.

Drawer is intentionally left on flex: its stylesheet is shared with the
CSS-only mobile="drawer" Popover/Dropdown/ContextMenu, which have no wrapper
and no JS to restructure around.

Verified desktop + mobile for both: desktop sheet single-column grid (blocks
fill, buttons natural), desktop sidebar grid header+scroll, mobile swipe
open/snap/dismiss intact, and the sidebar close stays grid every frame (no
full-width flex flicker).

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…tent

The no-wrapper grid forced layout responsibilities onto user content elements,
which collided with content styling: buttons needed justify-self pins, and the
horizontal inset broke for shrink-to-fit children (padding) or got overridden
by element resets (margin on a heading → flush-left Close button / heading).

SheetContent now renders a [k="sheet-panel"] wrapper holding the children, so:
- User content lays out in plain block flow — margins collapse, inline elements
  are natural width, nothing to neutralize. The 1rem inset is the wrapper's
  padding, isolated from content styling (fixes the flush-left button/heading).
- The dialog stays the swipe scroll container; on touch it's a flex row with a
  full-viewport ::before rail in front of the panel wrapper (a real element with
  its own background and scroll-snap-align), which drops the grid and the
  background-attachment:local gradient hack entirely. Net CSS is slightly
  smaller.
- The dialog scrolls only horizontally (the swipe); the panel scrolls its own
  content vertically, keeping the gesture axes independent.

Swipe JS, backdrop hit-testing and the --swipe:x contract are unchanged (the
dialog is still the scroller). Verified desktop block flow (heading + button
both inset) and mobile swipe open/dismiss. Sidebar stays on grid by design.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Giving the panel wrapper overflow-y:auto made it a scroll container — and CSS
coerces the other axis off `visible`, so it silently became a horizontal
scroll container too, capturing the swipe instead of letting it chain to the
dialog. Only the rail/backdrop region (still the dialog) stayed swipeable.

Restore the model the grid Sheet used: the dialog is the single 2-D scroller
(X scroll-snaps for the swipe, Y scrolls content freely) and the panel is a
plain flex item with overflow:visible — not a scroller. A swipe starting on the
panel now scrolls the dialog, exactly as it did when the children were direct
grid items.

Verified the panel computes overflow:visible (not a scroller), the dialog is
the X scroller, content stays inset in block flow, and dismiss still fires.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Adding contain to ScrollArea's base rule trapped the scroll wheel on desktop:
when the user scrolled to the edge of a scroll area, the page behind it
stopped scrolling. ScrollArea is just a styled scroll container — it shouldn't
contain by default. The swipe-overlay chaining (which set auto inside
Drawer/Sheet/Sidebar on touch) is also removed since the base no longer needs
an override.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…er mode

The desktop popover's closed state is a fade+scale (opacity: 0). In drawer
mode the enter/exit is a pure slide, and the drawer-mode transition lists
don't include opacity — so on close, the panel inherited the desktop base's
opacity: 0 with no transition and snapped invisible on the first frame. The
300ms slide-down still ran, but on a fully transparent element.

Pin opacity: 1 in the mobile=drawer base block so the closed state stays
opaque; the slide (transform on mobile, translate + display/overlay
allow-discrete in swipe mode) is the whole exit. Desktop fade is unaffected
(the pin lives inside the max-width/mobile="drawer" block).

Verified frame-by-frame in headless Chromium (touch emulation): backdrop tap
now slides the panel out over 300ms with opacity held at 1, display flipping
to none only when the transition completes.

https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants