The gallery ships. Thumbnails load. A visitor taps a photo and a white overlay throws the image into the middle of the screen, with a close button parked in the corner. The picture opened. The feel did not. That gap between opening and feeling is where galleries lose people, and it is the gap Ramka was built to close.
What Ramka actually does
Ramka is a lightbox for React, published as @ramka/reactunder the MIT license. The name is Polish for “frame”, which is what the component gives you: a frame that holds any media and gets out of the way.
The library is headless. It handles the dialog, the focus trap, the gestures and the transitions, and it ships no visual style at all. You compose the parts you need, name them yourself, and write the CSS. The only dependencies are react and react-dom at version 18 or newer, and both React 18.3 and React 19 are tested in CI.
Install it in one line, then import a namespace for the part you are building:
npm i @ramka/reactimport * as Lightbox from '@ramka/react/lightbox'
Why most lightboxes feel wrong
Two technical decisions cause most of the damage, and neither one shows up in a screenshot.
The first is the scroll. Plenty of galleries emulate swiping by translating a strip of slides with CSS transforms. It works on a desktop trackpad and falls apart on a phone, because the browser no longer owns the momentum. The swipe fights the finger.
The second is the opening. A lightbox that appears as a new state on top of the page reads as a page change. The eye has to rebuild the scene: where the photo was, where it went, what just happened. Shared-element transitions fix that by keeping one object on screen the whole time.
Ramka takes the honest version of both. Slides ride real CSS scroll snapping, so the swipe is native because it is native. The morph is a real shared-element transition rather than a crossfade pretending to be one.
☞ A lightbox is a gesture before it is a modal. Every decision that treats it as a modal leaks.
How the parts compose
The API follows the pattern you already know from Radix UI and Base UI. Each piece has one job and you decide the order:Root, Trigger, Portal, Backdrop, Content, Slides, Slide, Item, Zoom, Media, Caption, ThumbnailGroup, Counter, Previous, Next and Close. Nothing renders until you render it, and nothing has a color until you give it one.
One detail is worth copying into your own components. Ramka exports no prebuilt namespace object, because bundlers cannot shake a package that hands you one. Accessing Lightbox.Root stays statically visible to esbuild, so unused parts drop out of the bundle. Destructuring the namespace into local names keeps every part, which is exactly the kind of quiet regression nobody notices until a bundle report lands.
Since the parts carry no styling, the fastest way in is a preset. A copy-paste default preset gives you a working look in a few minutes, and colors and radii are tuned through documented --lightbox-* custom properties, so your preset and your tokens stay in one place.
The morph that sells the click
The morph is the part people notice, even if they never name it. The clicked tile and the fullscreen version share an element, so the browser interpolates position, size and corner radius in one motion. Going back reverses it, and the photo lands exactly where the visitor left it.
Anything can morph. Images, video, a 3D model and plain HTML all work, which means the same component covers a product gallery, a design portfolio and a screenshot viewer without a second library. Browsers without the View Transitions API get a clean crossfade, so the fallback is boring rather than broken.
That fallback matters more than it sounds. A transition that degrades into a flash of white is worse than no transition, because the visitor reads it as a bug. A crossfade reads as a fade.
Gestures, and where it stops
The gesture set is what you would expect from a native photo app, and the source of a few pleasant surprises:
- ❶ Swipe between slides on touch, with scroll snapping doing the work
- ❷ Pinch on a touchscreen or a trackpad, double-tap to zoom, and ctrl plus scroll to zoom at the cursor
- ❸ Pan with a finger, a mouse or the arrow keys
- ❹ Pull down, or scroll a trackpad, to dismiss
- ❺ Tab through the controls with the focus trapped inside the dialog
Pull to dismiss is elastic and interruptible, and it checks the zoom level and any swipe still in flight before it closes. Small detail, large effect: a dismiss gesture that ignores the current zoom will eat the photo the visitor was trying to inspect.
Every gesture above is documented with a live example at ramka.dev, where the docs run from the lightbox overview through view transitions and gestures to the full API reference. Try the pull gesture on a trackpad before you read the props table, because the feel is the part a spec sheet cannot carry.
Weight is the other argument. There are no runtime dependencies, the zoom, morph and pull engines load on demand, and a full gallery ships about 19 KB gzipped up front. For a component that only appears after a click, that number is the difference between adding it and skipping it.
☛ Where it stops. Ramka is pre-1.0, so pin the version. It ships no user-facing copy, so Close, Previous, Next and the zoom controls arrive without an aria-label. Give each one a label or your own visible text, or the dialog opens nameless and a development warning fires. If you need a player with ads, chapters and analytics hooks, keep your video platform. Ramka frames media, it does not manage it.
Wiring it into your own app
The setup is short enough to describe as a list:
- ❶ Install the package and import the lightbox namespace
- ❷ Wrap each grid tile in a
Triggerand pass the image ref it hands back - ❸ Put
Content,SlidesandZoomin a portal - ❹ Write the CSS, or start from the preset and override the variables
- ❺ Add a label to every icon-only control before you ship
Images accept srcset and work with next/image out of the box, and RTL layouts are handled by the component rather than by your overrides. If your gallery uses a masonry grid, the trigger side is unchanged: the morph starts from whatever element you point at.
Security teams get their own section in the README. The library runs under a strict style policy with style-src 'self' and style-src-attr 'none', with no unsafe-inline and no nonce to configure, because its styles arrive as constructed stylesheets and CSSOM writes rather than markup. Every release is published from GitHub Actions with npm trusted publishing, so each tarball carries a signed provenance attestation, and a CycloneDX SBOM ships inside the package. For a component that handles user photos, that paper trail is worth more than a feature list.
The article on designing an image upload component covers the state model that sits on the other side of this handoff, and the carousel design guide explains why snapping beats transform tricks for touch interfaces. If your gallery sits inside a slider, the slider rules apply to the same interaction budget.
Frequently asked questions
Does Ramka work in browsers without View Transitions?
Yes. The morph falls back to a crossfade, and every other part, including scroll snapping, zoom and pull to dismiss, keeps working. Nothing in the component depends on the API being present.
Is it a whole gallery or just the overlay?
It is both, in pieces. The overlay, the slide track, thumbnails, the counter and the navigation are separate parts, so a single-image viewer and a hundred-photo gallery use the same import with different composition.
Can I use it with next/image and responsive sources?
Yes. The media parts take a normal image element, so srcset and next/image both fit without a wrapper, and the trigger passes back the ref the morph needs.
What does headless cost me in setup time?
One styling pass. The default preset is copy-paste and close to production ready, and the documented CSS variables cover the rest. Expect an afternoon for a custom look, not a sprint.
Why it belongs in your stack
You already have a gallery. What you probably do not have is a viewer that feels native on a phone, keeps the photo in place through the transition, and ships less code than the average modal wrapper. Ramka is that piece, and it is small enough to try without a meeting.
Start from the GitHub repository, read the license, and point it at your three most viewed images. If the morph lands, keep going. When you want the grid around it to match, our free Figma files and design and code kits are built for the same fast, small-team workflow.

