@ocarignan/vitrine
v0.1.1
Published
A draggable project slider with a shared-element zoom lightbox for React. Self-contained, prop-driven, installable from npm.
Maintainers
Readme
Vitrine
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
@ocarignan/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.
Repo layout
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 published to npm as@ocarignan/vitrine.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.
Features
- 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 — each slider generates its own transition name, so several sliders can share a page.
- Three track variants (
variant): the scroll-snap row (default), a deck-cycling card stack, and a 3D coverflow. - Optional looping muted video per panel, autoplaying only while active,
with opt-in playback controls (
videoControls): a hover play/pause toggle on the panel, and a progress bar + mute / PiP / fullscreen in the lightbox. - Optional prev/next arrows and pagination dots beside the caption.
- The lightbox can be disabled entirely (
lightbox={false}), with anonItemClickcallback taking over the active panel's click. - 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. - Optional morphing icon cursor (
morphCursor) that follows the pointer and animates between+(slider panel),×(active lightbox item / overlay) and←/→(previous / next items), off by default. - Keyboard navigation:
←/→move the slider while it's focused or hovered, and drive the lightbox (←/→/Esc) while it's open.
Run the demo
cd demo
pnpm install # or npm install
pnpm dev # then open the printed localhost URLThe demo is a small routed site: the landing page (demo/src/pages/Home.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 docs section at /docs has
getting-started and API reference pages plus one live demo page per feature:
the default carousel (with both caption modes), video (+ playback controls),
lightbox options, the morphing cursor, the coverflow and card-stack variants,
and arrows & pagination.
The video page (demo/src/video-data.js) drives looping <video> panels
from the Pexels Video API — free, no-attribution
MP4 stock clips. It needs a free Pexels key in VITE_PEXELS_KEY (copy
demo/.env.example to demo/.env.local); without it, that demo is simply
skipped.
Install
pnpm add @ocarignan/vitrinereact and react-dom (>=18) are peer dependencies; motion and
@ocarignan/metamorphosis
(the morphing caption) come along as dependencies — both ship prebuilt, so
there's nothing to allowlist or build.
Import the component and its stylesheet once, then render with your items:
import { Slider } from "@ocarignan/vitrine";
import "@ocarignan/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. Opt into a morphing icon cursor with
the morphCursor prop (see below).
Variants
The variant prop swaps the track layout while keeping everything else —
captions, lightbox, keyboard nav, arrows/pagination:
<Slider items={items} /> // scroll-snap row (default)
<Slider items={items} variant="stack" /> // deck-cycling card stack
<Slider items={items} variant="coverflow" /> // 3D coverflowstack— cards rest in a loose pile, each keeping its own aspect ratio (differently-shaped photos, centered in a shared box) and leaning a few degrees, with a peek and scale falloff. The top card follows the cursor on both axes; throwing it past a threshold — any direction — tilts it into a flip to the back of the deck, which only ever moves forward. PassaspectRatiofor a tidy uniform deck instead of mixed shapes. The stack has no lightbox (lightboxis ignored): clicking the top card firesonItemClickif given, otherwise nothing.coverflow— the row engine with every item centered and projected in 3D: the centered panel lies flat, neighbors rotate toward the vanishing point, recede and shrink. Its lightbox mirrors the track's styling: items stay uniform cover-cropped boxes (so the zoom morphs crop → crop) and the lightbox neighbors carry the same 3D rotation.
Both variants ignore item.video in the track (poster image only); the
coverflow lightbox still plays it. Both loop endlessly by default — pass
loop={false} to clamp at the ends, or loop to make the row variant loop
too.
Video playback controls
Items with a video URL autoplay muted and looping while active. Pass
videoControls to add playback UI — no persistent chrome on the panel: hovering
the active panel reveals a morphing play/pause toggle (and softly blurs the
clip). The lightbox adds a centered play/pause toggle and a progress bar that
glides with playback and previews the seek target on hover; unless you pass
"minimal", the bar also expands on hover into a timestamp plus mute,
picture-in-picture, and fullscreen controls:
<Slider items={items} videoControls />
// Just play/pause + progress bar in the lightbox
<Slider items={items} videoControls="minimal" />The slider panel and the lightbox are separate <video> elements, so the mute
state and playback position don't carry across the zoom.
Arrows & pagination
<Slider items={items} arrows pagination />;arrows renders prev/next buttons beside the caption (disabled at the ends;
wrapping when the track loops). pagination renders one dot per panel — the
active dot tracks scrolling, and clicking a dot navigates.
Disabling the lightbox
<Slider
items={items}
lightbox={false}
onItemClick={(item, index) => openMyDetailPage(item)}
/>;With lightbox={false} no lightbox is mounted and clicking the active panel
fires onItemClick instead (clicking a non-active panel still centers it).
Without an onItemClick, panels are display-only.
Plain (non-animated) caption
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 "@ocarignan/vitrine";
<Slider items={items} Caption={PlainCaption} />;Any component with the ({ as, children }) contract works as a Caption —
bring your own.
Morphing icon cursor
By default the slider uses static inline-SVG cursors (+ to zoom in, × to
close). Pass morphCursor to replace them with a single cursor that follows the
pointer and morphs between icons depending on what's under it — powered by
metamorphosis's IconMorph:
<Slider items={items} morphCursor />;| Context | Hovered target | Icon |
| --------- | ---------------------------------- | ------------ |
| Slider | a panel | + |
| Lightbox | the active (centered) item | × |
| Lightbox | the overlay (backdrop / empty gaps)| × |
| Lightbox | the previous item | ← |
| Lightbox | the next item | → |
The cursor is white with a dark outline for contrast on any background. It only
activates on precise pointers (pointer: fine); touch devices, no-JS, and
prefers-reduced-motion fall back to the static cursors (the latter swaps icons
instantly instead of morphing). No extra setup — IconMorph ships with
metamorphosis, which is already a dependency.
Required CSS tokens
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.
<Slider> props
| Prop | Type | Default | Description |
| ------------------- | -------- | ---------------------------------- | ------------------------------------------------------------------ |
| items | Item[] | — | The panels (see shape below). |
| variant | "row" \| "stack" \| "coverflow" | "row" | Track layout: scroll-snap carousel, deck-cycling card stack, or 3D coverflow. Stack and coverflow ignore item.video in the track; the stack has no lightbox; the coverflow lightbox mirrors the track's styling. |
| 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.|
| aspectRatio | number \| string | — | Force panels into a fixed cover-cropped box — 1.5 or "16/9" / "3:2". Off by default (row and stack keep each image's own ratio); coverflow always uses a fixed box (default ≈ 3:4) and this overrides it. Bounded by maxItemHeight. |
| 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. |
| lightbox | boolean | true | Set false to disable the lightbox: no zoom, no lightbox mounted; the active panel's click fires onItemClick instead. Ignored by variant="stack", which never has a lightbox. |
| 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. |
| onItemClick | Function | — | Called with (item, index) when the active panel is clicked and the lightbox is disabled (or the variant is "stack"). |
| loop | boolean | variant-dependent | Endless scroll: the array loops seamlessly, in the slider and its lightbox. Defaults on for stack and coverflow, off for row. |
| arrows | boolean | false | Prev/next buttons beside the caption (disabled at the ends; wrapping when the track loops). |
| pagination | boolean | false | One dot per panel beside the caption; the active dot tracks scrolling, clicking a dot navigates. |
| videoControls | boolean \| "minimal" | false | Video playback UI: a hover play/pause toggle on the active panel; a play/pause toggle + gliding progress bar in the lightbox that, unless "minimal", expands on hover into timestamp + mute / PiP / fullscreen. Videos still autoplay muted. |
| transitionName | string | auto | Override the auto-generated per-instance shared-element view-transition name. |
| morphCursor | boolean | false | Replace the static SVG cursors with a morphing icon cursor: + over a panel, × over the active lightbox item / overlay, ← / → over the previous / next items. Precise-pointer only; touch and no-JS keep the static cursors. |
<Lightbox> props (internal)
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. |
| Caption | Component | TextMorph | Caption renderer (takes as + children). |
| controls | boolean | false | Render the prev / close / next buttons (on all breakpoints). |
| videoControls | boolean \| "minimal" | false | Play/pause toggle + progress bar over the active video item; unless "minimal", the bar expands on hover into timestamp + mute / PiP / fullscreen. |
| transitionName | string | "slider-active" | Shared-element view-transition name; <Slider> passes its per-instance name. |
| 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.
Item shape
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
// (row variant + lightbox — stack/coverflow show the poster)
}If highResSrc is omitted the lightbox just shows src at full size — no
placeholder/swap layer is rendered.
Notes
- 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. - Each
<Slider>generates its own per-instance view-transition name, so any number can share a page (override it with thetransitionNameprop if you need a stable name). Only mount onemorphCursorslider per page — each renders its own pointer-following cursor.
