# Styling

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"` on `Slide` gives desktop margins while phones stay edge to edge.
- **Viewport `gap`** spaces the slides while swiping.
- **`border-radius` on `Image`** is honoured, and morphs from the thumbnail's radius.
- **Stacking.** The overlay is portalled to `<body>`; give `Backdrop` and `Popup` a `z-index` if 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.

```tsx
<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](https://developer.mozilla.org/docs/Web/CSS/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](/docs/examples/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:

```css
.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`](https://github.com/shadcn-ui/cn). Copy it into your
project and make it yours.

```tsx
/**
 * 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>
  )
}
```
