A draggable horizontal slider with a shared-element zoom into a fullscreen lightbox. Extracted as a self-contained, prop-driven component so it can drop into any React project — no host grid system required.
Built with React 19, motion, and the native
View Transitions API
(progressive enhancement — falls back to a plain open/close where unsupported).
The caption morphs between projects by default, powered by
metamorphosis — a
dependency-free animated-text component that comes along as a dependency. Pass
the bundled PlainCaption (or your own component) to the Caption prop to opt
out of the animation.
This repo is both the published library and a live demo:
src/— the library.Slider,Lightbox, and the stylesheets. Built todist/withtsup(ESM + CJS) and installed straight from GitHub.demo/— a standalone Vite app that imports the library from../srcand doubles as a smoke test of the public entry. It has its ownpackage.json.
- Drag (with inertia + snap), wheel/trackpad scroll, and click-to-center.
- Click to zoom into a fullscreen, swipeable lightbox.
- Shared-element view transition between the panel and the lightbox image.
- Optional looping muted video per panel, autoplaying only while active.
- Progressive hi-res image swap in the lightbox (low-res placeholder → hi-res).
- Mobile-tuned: centered snap, depth scaling, and drag-down-to-dismiss the lightbox.
- Optional prev / close / next control bar in the lightbox (
lightboxControls), off by default. - Keyboard navigation:
←/→move the slider while it's focused or hovered, and drive the lightbox (←/→/Esc) while it's open.
cd demo
pnpm install # or npm install
pnpm dev # then open the printed localhost URLThe demo (demo/src/App.jsx, demo/src/demo-data.js) renders the slider with
random artworks pulled live from the
Art Institute of Chicago API — it searches for
public-domain, image-bearing works under a random term each load, so you get a
different set every refresh. A second slider below it (demo/src/video-data.js)
drives looping <video> panels from the
Pexels Video API — free, no-attribution MP4 stock
clips — showing the same component handling video items. That slider needs a free
Pexels key in VITE_PEXELS_KEY (copy demo/.env.example to demo/.env.local);
without it, the video section is simply skipped.
pnpm add github:olicarignan/vitrinereact and react-dom (>=18) are peer dependencies; motion and
metamorphosis (the morphing
caption) come along as dependencies. Both vitrine and metamorphosis build
themselves from source on install (a prepare script runs tsup). pnpm (v10+)
requires git deps with build scripts to be allowlisted, so add this to your
package.json:
"pnpm": { "onlyBuiltDependencies": ["vitrine", "metamorphosis"] }Import the component and its stylesheet once, then render with your items:
import { Slider } from "vitrine";
import "vitrine/styles.css";
<Slider items={items} />;The stylesheet is self-contained — the custom zoom cursors are inlined as data URIs, so there are no asset files to host.
The meta caption morphs between projects by default (letter morphing via
metamorphosis). To render it as plain <h3> / <p> instead, pass the bundled
PlainCaption to the Caption prop:
import { Slider, PlainCaption } from "vitrine";
<Slider items={items} Caption={PlainCaption} />;Any component with the ({ as, children }) contract works as a Caption —
bring your own.
The stylesheets read a few CSS custom properties — define them on :root (see
demo/src/demo.css):
| Token | Used for |
|---|---|
--gap |
gap between slider panels (desktop) |
--accent-color |
meta title color, focus ring, control-bar background (when enabled) |
--text-color |
meta subtitle color |
--color-text |
control-bar icon color — close + arrows (when enabled) |
To get the rest of the page to cross-fade during the zoom, also set
view-transition-name: root on :root.
| Prop | Type | Default | Description |
|---|---|---|---|
items |
Item[] |
— | The panels (see shape below). |
contentWidth |
number |
628 |
Desktop width (px) of the active panel's content column. |
gap |
number |
32 |
Desktop gap (px) between panels. |
columns |
number |
4 |
Notional grid columns — only used to align the meta text. |
metaOffsetColumns |
number |
0 |
Shift the meta text right by N columns on desktop (0 = flush). |
sideMargin |
number |
24 |
Minimum viewport margin (px/side) the content column keeps. |
maxItemHeight |
number |
520 |
Max height (px) of a panel; taller images scale down keeping ratio. |
sizes |
string |
(min-width: 700px) 628px, 82vw |
sizes hint for the panel <img>. |
lightboxSizes |
string |
84vw |
sizes hint forwarded to the lightbox images. |
Caption |
Component |
TextMorph |
Component used to render the meta title/subtitle. Receives as and children. Defaults to metamorphosis's morphing TextMorph; pass PlainCaption (or your own) to opt out. |
lightboxControls |
boolean |
false |
Show prev / close / next buttons in the lightbox (on all breakpoints). Off by default — the caption carries the context, and swipe / arrow keys navigate. |
The <Lightbox> is normally rendered and driven by <Slider> during the
shared-element zoom — you don't usually mount it yourself. If you do, these are
its props:
| Prop | Type | Default | Description |
|---|---|---|---|
items |
Item[] |
— | Same item array passed to <Slider>. |
activeIndex |
number |
— | Index to open on. |
sizes |
string |
84vw |
sizes hint for the images. |
controls |
boolean |
false |
Render the prev / close / next buttons (on all breakpoints). |
onActiveIndexChange |
Function |
— | Called with the new index as the user scrolls. |
onClose |
Function |
— | Called to dismiss the lightbox. |
Controls are off by default — the caption carries the context, and swipe / drag /
arrow keys navigate. Enable them with lightboxControls on <Slider> (or
controls on <Lightbox>): a fixed bar with prev / close / next buttons appears
below the caption on all breakpoints, with prev / next dimmed and disabled at the
first and last slide.
On mobile the lightbox can be dismissed by dragging the image down: the focused image follows your finger while the neighbours and backdrop fade out, and past a short threshold (or a quick flick) it closes — animating from where you released straight back to its slider panel. Below the threshold it springs back. Horizontal swipes still navigate.
All image fields are plain strings — bring your own CMS / image transform.
{
id, // unique key (falls back to array index)
title, // shown in the meta line
meta, // secondary meta line, e.g. "Brand · 2024"
src, // required: featured image URL
srcSet?, // responsive srcset
webpSrcSet?, // webp <source> srcset
blurDataURL?, // low-quality placeholder (data URI)
alt?, // defaults to title
highResSrc?, // hi-res image for the lightbox (falls back to src)
highResSrcSet?,
highResWebpSrcSet?,
video?, // looping muted video URL; autoplays only while active
}If highResSrc is omitted the lightbox just shows src at full size — no
placeholder/swap layer is rendered.
- Don't wrap the app in
<StrictMode>— its dev-only double-invoke of effects fights the one-pass measure / view-transition logic. Seedemo/src/main.jsx. - The view transition uses a single shared name (
slider-active), so render one<Slider>per page if you rely on the zoom animation.