Repo

Ramka is a set of unstyled lightbox primitives for React, in the style of Radix and Base UI. It owns the hard parts — focus trap, keyboard, swipe, pinch-zoom, pull-to-dismiss and a View Transition morph from thumbnail to full size. ZUI has no lightbox component, so Ramka fills the gap. ZUI ships an optional stylesheet that skins Ramka with ZUI's tokens, and ZUI's Button drops straight into its controls.

React only. Ramka is a React library. In Astro, Solid, Svelte or Vue projects, mount it inside a React island (as the demo does).

Click a photo. Swipe or use the arrow keys to move, pinch or double-click to zoom, and press Escape (or drag down on touch) to close:

Quick start

Three steps: install, import two stylesheets, paste one component.

1. Install

npm install @mrmartineau/zui @ramka/react @phosphor-icons/react

2. Import the styles

Import ZUI first, then the Ramka skin:

import '@mrmartineau/zui/css'
import '@mrmartineau/zui/css/ramka'

@mrmartineau/zui/css/ramka is opt-in. It is not part of the main zui.css bundle, so apps without a lightbox do not pay for it. Its rules sit in the zui.components layer, so your own CSS always wins.

Copy this component into your app. It is the same code as the demo above.

import { Button } from '@mrmartineau/zui/react'
import {
  CaretLeftIcon,
  CaretRightIcon,
  MagnifyingGlassMinusIcon,
  MagnifyingGlassPlusIcon,
  XIcon,
} from '@phosphor-icons/react'
import * as Lightbox from '@ramka/react/lightbox'

export type Photo = {
  id: string
  src: string
  thumb: string
  alt: string
  width: number
  height: number
}

export function PhotoGallery({ photos }: { photos: Photo[] }) {
  return (
    <Lightbox.Root>
      <div className="zui-ramka-grid">
        {photos.map((p, i) => (
          <Lightbox.Trigger key={p.id} index={i} aria-label={`Open ${p.alt}`}>
            {({ imageRef }) => <img ref={imageRef} src={p.thumb} alt={p.alt} />}
          </Lightbox.Trigger>
        ))}
      </div>

      <Lightbox.Portal>
        <Lightbox.Backdrop />
        <Lightbox.Content aria-label="Photo viewer">
          <Lightbox.Slides aria-label="Photos" aria-roledescription="carousel">
            {photos.map((p, i) => (
              <Lightbox.Slide key={p.id}>
                <Lightbox.Item
                  index={i}
                  caption={p.alt}
                  aria-roledescription="slide"
                  aria-label={`${i + 1} of ${photos.length}`}
                >
                  <Lightbox.Zoom>
                    <Lightbox.Media width={p.width} height={p.height}>
                      <img src={p.src} alt={p.alt} width={p.width} height={p.height} />
                    </Lightbox.Media>
                  </Lightbox.Zoom>
                </Lightbox.Item>
              </Lightbox.Slide>
            ))}
          </Lightbox.Slides>

          <div className="zui-ramka-toolbar">
            <Lightbox.ZoomOut aria-label="Zoom out" render={<Button variant="ghost" icon />}>
              <MagnifyingGlassMinusIcon aria-hidden />
            </Lightbox.ZoomOut>
            <Lightbox.ZoomIn aria-label="Zoom in" render={<Button variant="ghost" icon />}>
              <MagnifyingGlassPlusIcon aria-hidden />
            </Lightbox.ZoomIn>
            <Lightbox.Close aria-label="Close" render={<Button variant="ghost" icon />}>
              <XIcon aria-hidden />
            </Lightbox.Close>
          </div>

          <div className="zui-ramka-footer">
            <Lightbox.Counter />
            <Lightbox.Caption />
            <Lightbox.Previous aria-label="Previous photo" render={<Button variant="subtle" icon />}>
              <CaretLeftIcon aria-hidden />
            </Lightbox.Previous>
            <Lightbox.Next aria-label="Next photo" render={<Button variant="subtle" icon />}>
              <CaretRightIcon aria-hidden />
            </Lightbox.Next>
          </div>
        </Lightbox.Content>
      </Lightbox.Portal>
    </Lightbox.Root>
  )
}

Use it:

<PhotoGallery
  photos={[
    { id: 'fjord', src: '/photos/fjord.jpg', thumb: '/photos/fjord-thumb.jpg', alt: 'A fjord', width: 1600, height: 1000 },
    { id: 'pug', src: '/photos/pug.jpg', thumb: '/photos/pug-thumb.jpg', alt: 'A pug', width: 1000, height: 1250 },
  ]}
/>

How it fits together

Editorial example

A viewer for photos inside an article, like a news site. It is based on Ramka's Editorial preset. The viewer is dark in both site themes, shows a caption and a credit under each photo, and has large arrows at the left and right edges. There is no zoom: people read here, they do not inspect.

Click a photo in the article:

It uses the same stylesheet. Four changes from the gallery above:

  1. Add zui-ramka-editorial to Lightbox.Portal. It sets color-scheme: dark on the viewer. ZUI's colours use light-dark(), so every token and every ZUI Button inside turns dark with no more CSS.
  2. Pass caption and credit as one node. Lightbox.Item's caption takes any React node. Put the credit in a span with zui-ramka-credit.
  3. Put Previous and Next directly in Lightbox.Content. Outside the footer, they become edge arrows on desktop. On mobile they hide and people swipe.
  4. Leave out Lightbox.Zoom. Put Lightbox.Media straight in Lightbox.Item. The skin still centres and fits the photo.
import { Button } from '@mrmartineau/zui/react'
import { CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
import * as Lightbox from '@ramka/react/lightbox'

export type Figure = {
  id: string
  src: string
  thumb: string
  alt: string
  caption: string
  credit: string
  width: number
  height: number
}

export function ArticleFigures({ figures }: { figures: Figure[] }) {
  return (
    <Lightbox.Root
      loop
      // Keep the current figure in view when the viewer opens and closes.
      scrollTriggerIntoView={[
        { type: 'onOpenComplete', behavior: 'instant', block: 'nearest' },
        { type: 'onClose', behavior: 'instant', block: 'nearest' },
      ]}
    >
      {figures.map((f, i) => (
        <figure key={f.id}>
          <Lightbox.Trigger index={i} aria-label={`Open photo: ${f.alt}`}>
            {({ imageRef }) => (
              <img ref={imageRef} src={f.thumb} alt={f.alt} width={f.width} height={f.height} />
            )}
          </Lightbox.Trigger>
          <figcaption className="zui-text--1 zui-color-muted mt-2xs">
            {f.caption} <span className="zui-color-faint">{f.credit}</span>
          </figcaption>
        </figure>
      ))}

      <Lightbox.Portal className="zui-ramka-editorial">
        <Lightbox.Backdrop />
        <Lightbox.Content aria-label="Article photographs">
          <Lightbox.Slides aria-label="Article photographs" aria-roledescription="carousel">
            {figures.map((f, i) => (
              <Lightbox.Slide key={f.id}>
                <Lightbox.Item
                  index={i}
                  aria-roledescription="slide"
                  aria-label={`${i + 1} of ${figures.length}`}
                  caption={
                    <>
                      <span>{f.caption}</span>
                      <span className="zui-ramka-credit">{f.credit}</span>
                    </>
                  }
                >
                  <Lightbox.Media width={f.width} height={f.height}>
                    <img src={f.src} alt={f.alt} width={f.width} height={f.height} />
                  </Lightbox.Media>
                </Lightbox.Item>
              </Lightbox.Slide>
            ))}
          </Lightbox.Slides>

          <div className="zui-ramka-toolbar">
            <Lightbox.Counter />
            <Lightbox.Close aria-label="Close" render={<Button variant="ghost" icon />}>
              <XIcon aria-hidden />
            </Lightbox.Close>
          </div>

          <Lightbox.Previous aria-label="Previous photo" render={<Button variant="ghost" icon size="lg" />}>
            <CaretLeftIcon aria-hidden />
          </Lightbox.Previous>
          <Lightbox.Next aria-label="Next photo" render={<Button variant="ghost" icon size="lg" />}>
            <CaretRightIcon aria-hidden />
          </Lightbox.Next>

          <div className="zui-ramka-footer">
            <Lightbox.Caption />
          </div>
        </Lightbox.Content>
      </Lightbox.Portal>
    </Lightbox.Root>
  )
}

In a real article the figures sit between paragraphs, not in one loop. That is fine: any Lightbox.Trigger inside Lightbox.Root works, wherever it is.

Classes and attributes

The stylesheet styles Ramka's own data-ramka-* attributes, so most parts need no class. Add these three classes to your own wrapper elements:

Class Put it on What it does
.zui-ramka-grid the div around the triggers Responsive square thumbnail grid
.zui-ramka-toolbar a div inside Lightbox.Content Top-right bar for zoom and close
.zui-ramka-footer a div inside Lightbox.Content Bottom bar for counter, caption, previous and next. Hides on zoom
.zui-ramka-editorial Lightbox.Portal Dark article-figure viewer with a caption + credit block
.zui-ramka-credit a span inside an Item caption Small uppercase credit line

Without the grid class, triggers still work. Lay them out however you like.

Lightbox.Previous and Lightbox.Next placed directly inside Lightbox.Content become edge arrows on desktop and hide on mobile.

Customise

Set these custom properties on :root or any ancestor:

Property Default Controls
--zui-ramka-backdrop var(--color-background) Viewer background
--zui-ramka-inset var(--space-2xl) Space above and below the photo
--zui-ramka-radius var(--radius-lg) Corner radius of thumbnails and the photo

The default backdrop follows ZUI's light and dark themes. For a black, photo-first viewer in both themes:

:root {
  --zui-ramka-backdrop: var(--color-black);
}

[data-ramka-content] {
  color: var(--color-white);
}

For anything else, target Ramka's attributes in your own CSS. Unlayered CSS beats the skin without !important:

Ramka injects all of its view-transition CSS itself. The full list of parts, attributes and variables is in the Ramka API reference.

Without the stylesheet

To own every rule, skip the import and copy ramka.css into your project. It is under 300 lines and uses only ZUI tokens.

Notes

Theme

Copy this CSS to your project: