Gestures

Pull-to-dismiss, zoom/pan, and keyboard ownership. Part of Lightbox. For the usual gallery chrome (pull, zoom, keyboard) see the Default preset.

Content pullToClose

Content enables pull-to-dismiss by default (pullToClose). Drag down (touch) or wheel-dismiss (trackpad) scales the surface and sets data-pulling / data-pull-dismissing plus --lightbox-pull-progress (0→1).

  • Hit target is Stage-wide (or Content when Stage is omitted) — letterbox and empty Stage area can start a pull, matching trackpad wheel. Interactive controls (buttons, links, inputs, [data-ramka-no-pull]) keep their press. Side panels outside Stage do not start a pull.
  • Disabled while zoomed above 1×, and while Slides is scrolling until scrollend (so a gallery swipe that turns vertical cannot pull mid-gesture).
  • Backdrop is optional — when used, nest it inside Portal beside Content. Pull still works if you omit Backdrop.

Open/close fades live on .lightbox-demo-backdrop / .lightbox-demo-content (app/lightbox-demo/open-close.css). Pull then modulates the open scrim (media stays opaque) via pull-progress.css.

When a pull is released short of the threshold, the surface springs back and the library publishes --lightbox-pull-snap-duration / --lightbox-pull-snap-easing for the duration of that spring. Reference them in your transition (with your open/close values as the fallbacks, as below) and anything keyed to --lightbox-pull-progress — backdrop opacity, chrome, blur — eases back on the exact same curve and duration as the item, automatically.

Open / close CSS
.lightbox-demo-backdrop {
  opacity: 0;
  /* Fade duration cascade: pull snap-back vars while a dismiss springs back,
     otherwise the library-written --lightbox-vt-duration (the Root's
     `viewTransition` preset timing, always published on the Portal host —
     with or without a running morph). */
  transition: opacity
    var(--lightbox-pull-snap-duration, var(--lightbox-vt-duration, 360ms))
    var(--lightbox-pull-snap-easing, var(--ease-out-expo));
}

/* First frame when `data-open` appears — then pull-progress.css owns open opacity. */
@starting-style {
  .lightbox-demo-backdrop[data-open] {
    opacity: 0;
  }
}

.lightbox-demo-content {
  opacity: 0;
  /* --lightbox-vt-duration keeps the surface, scrim and image resolving
     together: same preset clock during a morph (documentElement) and on the
     plain fade path (Portal host). */
  transition: opacity var(--lightbox-vt-duration, 360ms) var(--ease-out-expo);
}

.lightbox-demo-content[data-open] {
  opacity: 1;
}

@starting-style {
  .lightbox-demo-content[data-open] {
    opacity: 0;
  }
}
Pull progress CSS
.lightbox-demo-backdrop[data-open] {
  /* --lightbox-pull-progress: 0 → 1 while dragging */
  opacity: calc(1 - var(--lightbox-pull-progress, 0) * 0.6);
}

.lightbox-demo-backdrop[data-pulling] {
  transition: none; /* follow the finger */
}

/*
 * Snug gallery slides — inactive cards track pull (`--lightbox-pull-progress`)
 * and zoom (`--lightbox-zoom-progress`, 0→1 by +1× above min). Zoom filter has
 * no transition (1:1 with progress); pull opacity snaps back via snap vars.
 */
/* Filter tracks pull + zoom with no transition (would lag the finger/pinch).
   Opacity still eases with pull snap-back.
   Inactive items only; `[data-active]` opts the active item back out. */
.lightbox-demo-slide-snug [data-ramka-item] {
  transition: opacity var(--lightbox-pull-snap-duration, 500ms)
    var(--lightbox-pull-snap-easing, var(--ease-out-expo));
}

.lightbox-demo-slide-snug [data-ramka-item]:not([data-active]) {
  --lb-snug-dim: max(var(--lightbox-pull-progress, 0), var(--lightbox-zoom-progress, 0));
  opacity: calc(1 - var(--lightbox-pull-progress, 0) * 0.85);
  filter: opacity(calc(1 - var(--lb-snug-dim)))
    blur(calc(var(--lb-snug-dim) * 6px));
}

.lightbox-demo-content[data-pulling] .lightbox-demo-slide-snug [data-ramka-item] {
  transition: none;
}

.lightbox-demo-content[data-zoomed] .lightbox-demo-slide-snug:has([data-ramka-item][data-active]) {
  z-index: 1;
}

/* Chrome fade — visibility keeps hidden controls out of tab order. */
.lightbox-demo-chrome-hide {
  opacity: 1;
  visibility: visible;
  transition:
    opacity 200ms,
    visibility 0s linear 0s;
}

@starting-style {
  .lightbox-demo-chrome-hide {
    opacity: 0;
  }
}

.lightbox-demo-content:is([data-pulling], [data-pull-dismissing]) .lightbox-demo-chrome-hide {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 200ms,
    visibility 0s linear 200ms;
}

.lightbox-demo-content[data-zoomed] .lightbox-demo-chrome-bottom.lightbox-demo-chrome-hide {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 200ms,
    visibility 0s linear 200ms;
}

@media (max-width: 767px) {
  .lightbox-demo-content[data-zoomed] .lightbox-demo-chrome-top.lightbox-demo-chrome-hide {
    opacity: 0;
    visibility: hidden;
    transition:
      opacity 200ms,
      visibility 0s linear 200ms;
  }
}

/* Light scrim used by LightboxGallery (shared docs viewer chrome). */
.lightbox-demo-backdrop-light {
  background-color: rgb(255 255 255 / 0.9);
}

html.dark .lightbox-demo-backdrop-light {
  background-color: rgb(0 0 0 / 0.9);
}

Zoom

Wrap media in Zoom (inside Item, typically inside Slide). Prefer Media inside Zoom so pan / maxZoom use the sized content box. Supports pinch, pan, double-tap, ctrl/meta+wheel (or scrollToZoom), and keyboard pan when focused.

Zoomed media can cover the full Content surface only if Slide → Item → Zoom → Media fill that box — see Composition → Media layout.

ZoomIn / ZoomOut can sit in toolbar chrome outside Zoom — they target the active slide via Root’s active-zoom bridge. Content reflects data-zooming / data-zoomed for styling.

Active-zoom bridge
import * as Lightbox from '@ramka/react/lightbox';

<Lightbox.Root>
  <Lightbox.Portal>
    <Lightbox.Backdrop />
    <Lightbox.Content>
      {/* Toolbar chrome — targets the active slide's Zoom */}
      <Lightbox.ZoomOut aria-label="Zoom out" />
      <Lightbox.ZoomIn aria-label="Zoom in" />

      <Lightbox.Slides>
        <Lightbox.Slide>
          <Lightbox.Item index={0}>
            <Lightbox.Zoom>
              <Lightbox.Media>
                <img src={src} alt={alt} />
              </Lightbox.Media>
            </Lightbox.Zoom>
          </Lightbox.Item>
        </Lightbox.Slide>
      </Lightbox.Slides>
    </Lightbox.Content>
  </Lightbox.Portal>
</Lightbox.Root>

Keyboard & focus

Content is the keyboard authority while open:

  • Escape — close
  • Arrow keys — pan when zoomed, otherwise previous/next
  • + / - / ⌘/Ctrl+0 — zoom in / out / reset (active Zoom)

Focus is trapped in the dialog; closing restores focus to the opening Trigger. Native page pinch-zoom is blocked while Content is mounted so it does not fight Lightbox.Zoom.

Interaction matrix

StatePullZoom chrome / buttonsPrev / Next
Idle, zoom = 1AllowedAllowedAllowed
Zoomed > 1BlockedAllowedArrows pan; buttons still navigate
Slides flingingBlockedBlockedBlocked until settle
PullingActiveShould idleShould idle