ramka

The case for a headless React lightbox

Here is a feature request from a popular lightbox’s issue tracker: hide the arrow buttons when the gallery has a single image. A reasonable ask. It was closed the same day, and the resolution was not a feature. It was a workaround, and “how do I do this” comments kept arriving for the next two years.

Hiding two buttons, config-object style
// The documented workaround, from the maintainer, for
// "hide the arrows when there is only one image":
const renderNavigation = slides.length <= 1 ? () => null : undefined;

<Lightbox
  render={{ buttonPrev: renderNavigation, buttonNext: renderNavigation }}
  // ...and a separate setting so the keyboard agrees with the buttons
  carousel={{ finite: true }}
/>

Pass a function that renders nothing into a render slot, conditionally, remembering the keyboard needs its own flag. Compare the same fix in a lightbox where the buttons are elements you were already rendering:

Hiding two buttons, composition style
{/* The same fix when the buttons are yours to render */}
{items.length > 1 && (
  <>
    <PrevButton />
    <NextButton />
  </>
)}

An if statement. Nobody files an issue for an if statement. That gap is what this post is about, and it has nothing to do with effort or code quality. The maintainer’s workaround is clever and the library it belongs to is good. The gap comes from the shape of the API, nothing else: the shape decides which problems are yours to solve in an afternoon and which ones are feature requests to a stranger.

The three shapes of lightbox APIs

Survey the popular lightbox and image gallery libraries and nearly every API is one of three shapes. No names here, because the point is not that these libraries are bad. They are the most polished versions of their pattern. The point is the ceiling each pattern builds in.

Shape one: a selector and a config object. You hand the library a CSS selector and it takes the DOM from there. The markup inside the viewer is the library’s, so adding your own button means serializing your UI into a string and aiming it with a magic number:

Shape one: UI as strings, position as a number
const lightbox = new Lightbox({
  gallery: '#gallery',
  children: 'a',
});

// A custom toolbar button is an HTML string with a z-order.
// The built-ins have fixed slots: the counter is order 5,
// zoom is 10, info is 15, the close button is order 20.
lightbox.on('uiRegister', () => {
  lightbox.pswp.ui.registerElement({
    name: 'share-button',
    ariaLabel: 'Share photo',
    order: 9,
    isButton: true,
    html: '<svg viewBox="0 0 24 24">…</svg>',
    onClick: (event, el) => {
      /* … */
    },
  });
});

Your share button is html: '<svg>…</svg>'. Not a component. A string, positioned relative to the built-ins by knowing that the close button lives at order 20. If the button needs state, a tooltip from your design system, or a React portal, you are now managing a DOM island inside someone else’s DOM.

Shape two: data attributes. The gallery is declared in HTML — a lineage that runs straight back to 2005’s rel="lightbox" attribute — which sounds friendly until your caption is a heading and a paragraph:

Shape two: your caption is HTML in a string in an attribute
<!-- The caption is HTML, in a string, in an attribute. -->
<a
  href="photos/coast.jpg"
  data-sub-html="<h4>Golden hour</h4><p>Shot on the coast road.</p>"
>
  <img src="photos/coast-thumb.jpg" alt="Golden hour" />
</a>

HTML, in a string, in an attribute, waiting to be parsed and injected at open time. Everything else routes through the config object: the next and previous buttons are nextHtml and prevHtml option strings, translations are a strings map, and even ARIA wiring arrives as options:

Shape two: everything is a setting
lightbox(element, {
  licenseKey: '0000-0000-000-0000',
  nextHtml: '<svg>…</svg>', // the next button, as a string
  prevHtml: '<svg>…</svg>',
  strings: { closeGallery: 'Close gallery' }, // i18n, as config
  ariaLabelledby: 'gallery-heading', // ARIA, as config
  mobileSettings: { controls: false, showCloseIcon: true },
});

One library of this shape documents more than sixty core settings before you have installed a single plugin, and the plugins bring their own. That number is not bloat. It is the honest cost of keeping the DOM: every decision the library makes for you has to come back to you as an option.

Shape three: React, but still a config object. The modern React take accepts arrays and maps instead of selectors and strings, which is real progress. But look at where your UI goes:

Shape three: composition through a keyhole
<Lightbox
  open={open}
  close={() => setOpen(false)}
  slides={[{ src, title, description }]}
  plugins={[Captions, Counter, Thumbnails, Zoom]}
  labels={{ Next: 'Next photo', Close: 'Close' }}
  render={{
    buttonPrev: () => <MyPrevButton />,
    iconClose: () => <MyCloseIcon />,
  }}
  styles={{ container: { ... }, button: { ... }, icon: { ... } }}
/>

Slides are data, features are a plugin array, words are a labels map, your components enter one at a time through named render slots, and styling gets five sanctioned customization points. This is composition through a keyhole. Each slot is real and each slot works, but the set of slots is finite, and it is exactly the set the author thought to cut. To its credit, this shape cuts generous holes — there is a slot for rendering whole custom slides and one for overlaying extra controls — yet each is still a named opening in the same wall, usable exactly where the author aimed it. The one-image workaround that opened this post lives in this shape, and no wonder: when rendering belongs to the library, you customize by exception.

One more thing all three shapes share: step zero of every install guide is importing the library’s stylesheet. That import sets the terms for every pixel after it. Making the viewer look like your product means overriding someone else’s selectors. The better libraries document CSS variables and class names for exactly this, but the documented list is one more config surface, and everything off the list is an internal you hope survives the next release. Dark mode has to undo their colors before applying yours. And the bytes ship regardless, including the ones for pixels you just repainted. A required stylesheet is the config object of styling: a list of decisions someone else already made, plus escape hatches.

Why config APIs grow forever

None of these authors set out to build a settings museum. The options accumulate one reasonable request at a time. Someone needs the counter on the left. Someone needs captions under the image instead of over it. Someone needs the close button to say “Schließen”. When the library owns the DOM, the only way to say yes is a new option, so the options page becomes a list of every customization the author predicted. The requests the author did not predict become wrapper-div hacks, CSS fights against inline styles, forks, or that politely closed issue with a workaround.

The deeper problem is that a config API has to be designed twice. Once for the feature, once for every way someone might want the feature to differ. A rendered element needs no second design. If you render the counter, you can also move it, restyle it, translate it, or wrap it in a tooltip, and none of those verbs needed to be anticipated by anyone.

The ecosystem already voted

This is not some niche philosophy. The React ecosystem already builds every other overlay this way. Headless primitives sit under a huge share of production dialogs, menus, and selects: the most-installed primitive library counts its npm downloads in the hundreds of millions per month at the time of writing, and the most-starred React UI project on GitHub, at roughly 122,000 stars, is a CLI that copies composable component source into your repo so you own every line. In the State of React survey, its usage doubled from 20 to 42 percent in a single year. Adoption numbers are not a controlled study of motives, but the direction is hard to misread: offered both models, developers keep picking ownership.

React lightboxes mostly sat this shift out, for a defensible reason. A fullscreen image viewer is the hardest overlay there is: scroll physics, pinch zoom, pull to dismiss, morph transitions. Authors kept the DOM because handing it over risks the physics. ramka’s bet is that the two concerns split cleanly: the physics live in the engine, on a real scroller the browser drives, and the DOM lives with you, the way Radix, Base UI, and shadcn/ui trained everyone to expect.

What owning the markup buys

Here is the whole API shape. It should look familiar if you have used any headless primitive:

ramka: the API is the markup
<Lightbox.Root>
  <Lightbox.Trigger index={0}>
    <img src={photo.thumb} alt={photo.alt} />
  </Lightbox.Trigger>

  <Lightbox.Portal>
    <Lightbox.Backdrop className="…" />
    <Lightbox.Content aria-label={t('viewer.title')} className="…">
      <Lightbox.Slides className="…">{/* your slides */}</Lightbox.Slides>

      {/* Every element below is yours: attach anything an element can carry. */}
      <Tooltip content={t('viewer.closeHint')}>
        <Lightbox.Close aria-label={t('viewer.close')} data-testid="viewer-close">
          <X />
        </Lightbox.Close>
      </Tooltip>
    </Lightbox.Content>
  </Lightbox.Portal>
</Lightbox.Root>

Every visible piece of chrome in the viewer is one you rendered — the library’s own additions are plumbing, like the focus guards and the portal host, never a button or a pixel of UI — so every element can carry whatever your app needs it to carry. An aria-label in your user’s language rather than a hard-coded English string. The tooltip component the rest of your product uses. A data-testid for the end-to-end suite, an analytics attribute for the click tracker. Your Image component as the slide, with its own srcset logic and placeholders. None of that is a feature of ramka. It is what falls out of the library not standing between you and your own elements.

Styling follows the same rule. There is no ramka.css, so there is nothing to import, nothing to override, and no specificity contest to win. The primitives apply only the functional styles they cannot work without, like the scroll and snap geometry on Slides; every visual decision waits for you. Every part takes a className like any element you wrote, whether that means Tailwind, CSS Modules, or your design tokens; every viewer on this site is styled with plain Tailwind against these same primitives. And an upgrade cannot break your styles by renaming a private selector or reshuffling an internal DOM you were forced to target, because your styles were never attached to either.

Do not take the claim on faith. Below is a viewer in the split, social-app layout, media pane plus post thread, built from the same primitives. Toggle its pieces and watch the JSX follow:

Mia Kowalska@miakow · 3h

Three frames from the weekend that I keep coming back to. The fog one is straight out of camera.

Your chrome, exactly as rendered
<Lightbox.Portal>  <Lightbox.Backdrop className="…" />  <Lightbox.Content aria-label="Photos by Mia" className="…">    <Lightbox.Stage className="…">      {/* wrapped in your own tooltip component */}      <Tooltip content="Close (Esc)">        <Lightbox.Close aria-label="Close"><X /></Lightbox.Close>      </Tooltip>      <Lightbox.Slides className="…">        {items.map((item, i) => (          <Lightbox.Slide key={item.id}>            <Lightbox.Item index={i} caption={item.caption}>              <img src={item.src} alt={item.alt} />            </Lightbox.Item>          </Lightbox.Slide>        ))}      </Lightbox.Slides>      <Tooltip content="Previous (←)">        <Lightbox.Previous aria-label="Previous"><ChevronLeft /></Lightbox.Previous>      </Tooltip>      <Tooltip content="Next (→)">        <Lightbox.Next aria-label="Next"><ChevronRight /></Lightbox.Next>      </Tooltip>      <Lightbox.Caption className="…" />      <Lightbox.ThumbnailStrip className="…">        <Lightbox.ThumbnailStripTrack aria-label="Thumbnails">          {items.map((item, i) => (            <Lightbox.Thumbnail key={item.id} index={i}>              <img src={item.thumb} alt={item.alt} />            </Lightbox.Thumbnail>          ))}        </Lightbox.ThumbnailStripTrack>      </Lightbox.ThumbnailStrip>    </Lightbox.Stage>    {/* a real sidebar: your markup, not a plugin */}    <aside aria-label="Post">      <PostThread post={post} />    </aside>  </Lightbox.Content></Lightbox.Portal>
Flip the switches, then open a photo. The viewer and the JSX update together because they cannot drift apart: a piece of chrome exists exactly when you render it. The post sidebar is a plain <aside> with your components in it, the tooltips wrap every icon button in the stage, and none of it is an option. The sidebar layout is desktop-only, like the social apps it mimics.

Build this yourself with ramka’s composition guide →

What a keyhole can never fit

Render slots cover the customizations the author predicted, and predictions run out fast. The requests that end as wontfix in config lightboxes are the ones where your UI is not replacing a built-in piece but adding structure the config schema has no word for. A comment thread beside the photo, say, the pattern every social app uses:

Structure no config schema has a word for
<Lightbox.Content className="grid md:grid-cols-[1fr_360px]">
  {/* Stage scopes the gestures to the media pane */}
  <Lightbox.Stage>
    <Lightbox.Slides>{/* … */}</Lightbox.Slides>
  </Lightbox.Stage>

  {/* A real sidebar: your components, live beside the photo,
      with native scroll and its own keyboard behavior. */}
  <aside aria-label="Comments">
    <CommentThread photoId={activePhotoId} />
  </aside>
</Lightbox.Content>

In ramka that is a grid with an aside in it, because the viewer is your markup and Stage scopes the gestures to the media pane. It is the exact structure the “Post sidebar” switch was toggling in the demo above. In a config lightbox the nearest escape hatch is a slot for overlaying absolutely positioned elements on top of the viewer, which is not the same as giving the photo a neighbor: a comment sidebar drags in layout, scrolling, focus, forms, and state that no options object can describe. The same logic covers the morph targeting any element you choose via morphRef, the counter rendering wherever the design puts it, and video or a canvas or a diagram as a slide. Not because ramka shipped a feature for each, but because there is nothing to ask permission from.

What config APIs do better

Honesty section, as usual. The config shapes won their popularity for a reason: a working viewer in three lines is a genuinely great pitch, and composition cannot match it. A headless lightbox hands you a blank page, and a blank page is a real cost. You will write markup on day one that a config library would have given you for free.

The answer is the same one shadcn/ui gave for every other component: start from source you own. ramka’s presets are complete, styled viewers, photos, stories, a Twitter-style feed, that you copy into your project and edit like code you wrote, because from that moment it is. Day one costs a paste. And the first time you need anything the options page would not have listed, the trade flips: your gnarliest customization is an if statement, a class name, or an aside, in markup you already own.

Feel it in a real photo gallery

The gallery below is the composed result: morph open, native swipe, pinch zoom, pull to dismiss, and every visible element, buttons, counter, caption, thumbnails, is consumer markup styled with Tailwind. The library shipped the behavior and zero pixels of the chrome.

That is the whole pitch: ramka is a headless React lightbox. The behavior ships in the library; the visible structure and styling live in your codebase; the presets keep day one cheap. The composition guide is a ten-minute tour of the markup, and the lightbox docs go from install to first viewer. ramka is free for open source under the GPL, and one payment covers proprietary use for your whole team. Bring your own buttons.