npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 to dist/ with tsup (ESM + CJS) and published to npm as @ocarignan/vitrine.
  • 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 — 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 an onItemClick callback 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 URL

The 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/vitrine

react 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 coverflow
  • stack — 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. Pass aspectRatio for a tidy uniform deck instead of mixed shapes. The stack has no lightbox (lightbox is ignored): clicking the top card fires onItemClick if 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. See demo/src/main.jsx.
  • Each <Slider> generates its own per-instance view-transition name, so any number can share a page (override it with the transitionName prop if you need a stable name). Only mount one morphCursor slider per page — each renders its own pointer-following cursor.