Lightbox

Styling

View as Markdown

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.

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