The package ships zero CSS. Style every part with whatever you already use. These docs use Tailwind v4 and cn.
What the library sets
Only what the mechanics need, as inline styles you can override: the popup and backdrop are position: fixed; inset: 0,
the viewport is a horizontal scroll-snap flex row, slides are flex: 0 0 100%, and the image is absolutely positioned
inside its slide. Everything else — colours, spacing, z-index, chrome — is yours.
- Slide padding is the image’s breathing room. The image is fitted (
contain) inside the slide’s content box.className="sm:p-16"onSlidegives desktop margins while phones stay edge to edge. - Viewport
gapspaces the slides while swiping. border-radiusonImageis honoured, and morphs from the thumbnail’s radius.- Stacking. The overlay is portalled to
<body>; giveBackdropandPopupaz-indexif your page has sticky headers.
State
| Attribute | On | Meaning |
|---|---|---|
data-phase |
Popup | opening · open · closing |
data-zoomed |
Popup, Viewport | The current image is zoomed in |
data-dragging |
Popup | Pull-to-dismiss in progress |
data-ui-hidden |
Popup | User tapped the image to hide chrome |
data-current |
Slide | The slide in view |
data-active |
Trigger | Its item is open (its <img> is hidden meanwhile) |
data-loaded |
Image | The full image has loaded |
data-disabled |
Prev, Next | At the first / last item |
--lightbox-presence |
Popup, Backdrop | 0 → 1 as the viewer opens; follows pull-to-dismiss and pinch-to-close |
--lightbox-presence is what makes chrome feel attached to the photo: anything that should fade with the backdrop reads it.
<div className="opacity-(--lightbox-presence)">…</div>
<div className="transition-opacity group-data-dragging:opacity-0 group-data-ui-hidden:opacity-0">…</div>
Scroll-driven effects
Slides are plain elements in a real scroller, so CSS scroll-driven animations work on them with no JavaScript. Animate an element inside the slide, never the slide itself: browsers compute snap points from the slide’s transformed box, so transforming it moves the snap targets mid-swipe.
The Stories cube makes each slide exactly card-wide (so neighbouring cards share an edge and one swipe is one face), then turns a full-size child of each slide with the scroll position. Each face cancels its scroll offset, shares one vanishing point at the screen centre and rotates about the cube’s axis half a face-width behind the screen; the animation range spans one face either side of centre, so neighbours rest edge-on:
.story-face {
animation: story-cube linear both;
animation-timeline: view(inline);
animation-range: cover calc(50% - var(--card-w)) cover calc(50% + var(--card-w));
backface-visibility: hidden;
}
@keyframes story-cube {
0% {
transform: translateX(calc(var(--card-w) * -1)) perspective(calc(var(--card-w) * 2.5))
translateZ(calc(var(--card-w) / -2)) rotateY(90deg) translateZ(calc(var(--card-w) / 2));
filter: brightness(0.45);
}
50% {
transform: translateX(0) perspective(calc(var(--card-w) * 2.5))
translateZ(calc(var(--card-w) / -2)) rotateY(0deg) translateZ(calc(var(--card-w) / 2));
}
100% {
transform: translateX(var(--card-w)) perspective(calc(var(--card-w) * 2.5))
translateZ(calc(var(--card-w) / -2)) rotateY(-90deg) translateZ(calc(var(--card-w) / 2));
filter: brightness(0.45);
}
}
A styled recipe
The demos on this site use one file built with Tailwind v4 and cn. Copy it into your
project and make it yours.
/**
* A styled viewer recipe: the headless @frontendsupply/lightbox parts + Tailwind v4 + cn.
* Copy this file into your app and make it yours — nothing here is part of the library.
*/
import { Lightbox, type LightboxItem, useLightbox } from '@frontendsupply/lightbox'
import { cn } from 'cn'
import type * as React from 'react'
export { Lightbox, useLightbox }
const iconButton =
'pointer-events-auto grid size-10 place-items-center rounded-full bg-white/10 text-white backdrop-blur-md transition outline-none hover:bg-white/20 active:scale-95 focus-visible:ring-2 focus-visible:ring-white/60 disabled:pointer-events-none disabled:opacity-0'
const Icon = ({ d, className }: { d: string; className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={cn('size-5', className)}
aria-hidden
>
<path d={d} />
</svg>
)
const icons = {
close: 'M18 6 6 18M6 6l12 12',
prev: 'm15 18-6-6 6-6',
next: 'm9 18 6-6-6-6',
plus: 'M12 5v14M5 12h14',
minus: 'M5 12h14',
}
/** Fades with the backdrop (`--lightbox-presence`) and hides while dragging or after a tap. */
export function Chrome({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<div className="pointer-events-none absolute inset-0 opacity-(--lightbox-presence)">
<div
className={cn(
'absolute inset-0 transition-opacity duration-200 group-data-dragging:opacity-0 group-data-ui-hidden:opacity-0',
className,
)}
>
{children}
</div>
</div>
)
}
export function Toolbar({ zoom = true }: { zoom?: boolean }) {
const { index, count, zoomed, zoomIn, zoomOut } = useLightbox()
return (
<div className="absolute inset-x-0 top-0 flex items-center justify-between p-3 pt-[max(0.75rem,env(safe-area-inset-top))]">
<span className="rounded-full bg-black/30 px-3 py-1.5 text-sm tabular-nums backdrop-blur-md">
{index + 1} / {count}
</span>
<div className="flex gap-2">
{zoom && (
<>
<button
type="button"
className={cn(iconButton, 'pointer-coarse:hidden')}
onClick={zoomOut}
disabled={!zoomed}
aria-label="Zoom out"
>
<Icon d={icons.minus} />
</button>
<button
type="button"
className={cn(iconButton, 'pointer-coarse:hidden')}
onClick={zoomIn}
aria-label="Zoom in"
>
<Icon d={icons.plus} />
</button>
</>
)}
<Lightbox.Close className={iconButton} aria-label="Close">
<Icon d={icons.close} />
</Lightbox.Close>
</div>
</div>
)
}
export function Arrows() {
const button = cn(iconButton, 'absolute top-1/2 -translate-y-1/2 pointer-coarse:hidden')
return (
<>
<Lightbox.Prev className={cn(button, 'left-3')}>
<Icon d={icons.prev} />
</Lightbox.Prev>
<Lightbox.Next className={cn(button, 'right-3')}>
<Icon d={icons.next} />
</Lightbox.Next>
</>
)
}
export function Caption({ className }: { className?: string }) {
const { item, index, count } = useLightbox<LightboxItem & { caption?: string }>()
return (
<div
className={cn(
'absolute inset-x-0 bottom-0 px-4 pt-10 pb-[max(1.25rem,env(safe-area-inset-bottom))] text-center',
className,
)}
>
<p className="text-sm font-medium">{item?.caption ?? item?.alt}</p>
<p className="mt-0.5 text-xs text-white/50 tabular-nums">
{index + 1} of {count}
</p>
</div>
)
}
/** Thumbs keep their aspect ratio; the ring glides between them as you swipe, resizing on the way. */
export function Thumbnails() {
const { items } = useLightbox<LightboxItem & { thumb?: string }>()
return (
<Lightbox.Thumbnails className="pointer-events-auto absolute inset-x-0 bottom-0 flex items-center gap-2 px-[50%] pt-4 pb-[max(0.875rem,env(safe-area-inset-bottom))]">
{items.map((item, i) => (
<Lightbox.Thumbnail
key={item.src}
index={i}
className="h-11 shrink-0 overflow-hidden rounded-[5px] opacity-45 transition-opacity duration-300 outline-none hover:opacity-80 focus-visible:opacity-100 data-active:opacity-100"
style={{ aspectRatio: item.width && item.height ? `${item.width} / ${item.height}` : undefined }}
>
<img src={item.thumb ?? item.src} alt="" className="size-full object-cover" draggable={false} />
</Lightbox.Thumbnail>
))}
<Lightbox.ThumbnailIndicator className="rounded-[5px] outline-2 outline-offset-2 outline-white" />
</Lightbox.Thumbnails>
)
}
export function Slides({ className, imageClassName }: { className?: string; imageClassName?: string }) {
const { items } = useLightbox()
return (
<Lightbox.Viewport className="gap-6">
{items.map((item, i) => (
<Lightbox.Slide key={item.src} index={i} className={cn('sm:px-16 sm:pt-16 sm:pb-24', className)}>
<Lightbox.Image className={imageClassName} />
</Lightbox.Slide>
))}
</Lightbox.Viewport>
)
}
/** The whole overlay. Put it anywhere inside <Lightbox.Root>. */
export function Viewer({ className, children }: { className?: string; children?: React.ReactNode }) {
return (
<Lightbox.Portal>
<Lightbox.Backdrop className="z-50 bg-neutral-950" />
<Lightbox.Popup className={cn('group z-50 text-white outline-none', className)}>
{children ?? (
<>
<Slides />
<Chrome>
<Toolbar />
<Arrows />
<Caption />
</Chrome>
</>
)}
</Lightbox.Popup>
</Lightbox.Portal>
)
}