A draggable horizontal project 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 the centered panel 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 a floating prev / close / next control bar.
- 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 Met Collection API — it searches for
image-bearing works under a random term each load, so you get a different set
every refresh.
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, mobile control-bar background |
--text-color |
meta subtitle color |
--color-text |
mobile control-bar icon color (close + arrows) |
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. |
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. |
onActiveIndexChange |
Function |
— | Called with the new index as the user scrolls. |
onClose |
Function |
— | Called to dismiss the lightbox. |
On mobile (< 700px) the lightbox shows a fixed control bar with prev / close /
next buttons; prev and next dim and disable at the first and last slide.
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.