Skip to content

olicarignan/vitrine

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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 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 to dist/ with tsup (ESM + CJS) and installed straight from GitHub.
  • demo/ — a standalone Vite app that imports the library from ../src and doubles as a smoke test of the public entry. It has its own package.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.
  • 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.

Run the demo

cd demo
pnpm install   # or npm install
pnpm dev       # then open the printed localhost URL

The 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.

Install

pnpm add github:olicarignan/vitrine

react 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.

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 "vitrine";

<Slider items={items} Caption={PlainCaption} />;

Any component with the ({ as, children }) contract works as a Caption — bring your own.

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).
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.

<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.
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.

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
}

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. See demo/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.

About

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors