Default
Pick a styling flavor with the toggle: Tailwind is a single
lightbox.tsx (utilities only, no stylesheet), CSS pairs lightbox.tsx with lightbox.css. Both export the identical styled Lightbox namespace — drop the Package files into a design-system entry, then compose triggers in your app. Gallery includes a top-right Bleed / Snug toggle (desktop) to switch between full-width and aspect-sized slide tracks. The Example tab shows the live preview (+N crossfade, child morphRef, whole-trigger morph) — it is app UI, not part of the skin.See also: Lightbox overview
+N overlay with crossfade
Child morph via morphRef
Whole trigger morph
Single file, no stylesheet. Requires Tailwind CSS v4, the shadcn `cn` helper (@/lib/utils), and lucide-react.
'use client';
/**
* Default lightbox, Tailwind variant — single-file design-system package
* entry (no CSS file to copy). A plain-CSS variant with the identical
* component API lives in `../css/lightbox.tsx`.
*
* Re-exports ramka Lightbox primitives with this skin attached, plus composed
* `Lightbox.Gallery` (bleed or snug slides, glass controls, desktop thumbnail strip).
*
* Conventions:
* - Requires Tailwind CSS v4 (`starting:`, `not-*`, bare `data-*` variants)
* plus the shadcn `cn` helper and `lucide-react`.
* - Variables named `--lightbox-*` are written by the ramka runtime (gesture
* progress, motion timing) — this file only reads them. Variables named
* `--lb-*` are this skin's own tokens, declared via arbitrary properties.
* - Gesture-driven state rides on data attributes ramka sets on Content
* (`data-open`, `data-pulling`, `data-pull-dismissing`, `data-zoomed`),
* consumed here through the named `group/lb` on Content. All
* view-transition CSS (morph group, snapshots, crossfade) is injected by
* the library; the skin ships none of it.
*
* Package boundary: only the styled primitives + Gallery. Trigger layouts,
* album grids, user cards, and other app UI belong in the consuming app
* (docs: `demo.tsx`) — not in this module.
*/
import * as React from 'react';
import {
GalleryHorizontalIcon,
RectangleHorizontalIcon,
XIcon,
ZoomInIcon,
ZoomOutIcon,
} from 'lucide-react';
import * as RamkaLightbox from '@ramka/react/lightbox';
import { cn } from '@/lib/utils';
/** Item shape for `Lightbox.Gallery` — pass real intrinsic pixel size. */
export type LightboxItem = {
id?: string | number;
src: string;
thumb: string;
alt: string;
caption?: string;
width: number;
height: number;
};
export type SlidesLayout = 'bleed' | 'snug';
/**
* The snug/bleed choice is a user preference, so it survives across galleries
* and visits. Guarded reads/writes: localStorage throws in some private modes,
* and `window` doesn't exist during SSR (the portal renders nothing while
* closed, so the pre-hydration value is never visible anyway).
*/
const SLIDES_LAYOUT_STORAGE_KEY = 'ramka-lightbox-slides-layout';
function readStoredSlidesLayout(): SlidesLayout | null {
if (typeof window === 'undefined') return null;
try {
const value = window.localStorage.getItem(SLIDES_LAYOUT_STORAGE_KEY);
return value === 'snug' || value === 'bleed' ? value : null;
} catch {
return null;
}
}
function storeSlidesLayout(layout: SlidesLayout) {
try {
window.localStorage.setItem(SLIDES_LAYOUT_STORAGE_KEY, layout);
} catch {
// Storage unavailable — the toggle still works for this session.
}
}
function itemAspectRatio(item: LightboxItem): number {
return item.width / item.height;
}
/* ── Shared class recipes ───────────────────────────────────────────────── */
/**
* Glass control pill (iOS-style liquid glass). The focus ring rides on
* box-shadow (the pill clips outlines poorly), so focus-visible restates the
* resting shadows plus the ring. Hover/press tint is a background-image
* overlay so one recipe serves both the standalone pills (which have a
* background-color) and the transparent segments inside a control group.
* Note `scale` in the transition list: Tailwind v4 scale utilities write the
* `scale` property, not `transform`.
*/
const controlClass = cn(
'inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full',
'border border-black/20 bg-white/55 text-neutral-900',
'shadow-[0_0.5px_1px_rgb(0_0_0/0.12),0_1px_2px_rgb(0_0_0/0.08),inset_0_0.5px_0_rgb(255_255_255/0.65)]',
'backdrop-blur-[40px] backdrop-saturate-150',
'transition-[background-color,box-shadow,transform,scale,opacity,color] duration-150 ease-out',
'outline-none',
'hover:[background-image:linear-gradient(rgb(0_0_0/0.05),rgb(0_0_0/0.05))]',
'active:scale-[0.96] active:[background-image:linear-gradient(rgb(0_0_0/0.09),rgb(0_0_0/0.09))]',
'focus-visible:shadow-[0_0_0_2px_rgb(0_0_0/0.25),0_0.5px_1px_rgb(0_0_0/0.12),0_1px_2px_rgb(0_0_0/0.08),inset_0_0.5px_0_rgb(255_255_255/0.65)]',
'disabled:pointer-events-none disabled:opacity-30',
'[&_svg]:block [&_svg]:size-4',
'dark:border-white/25 dark:bg-neutral-900/45 dark:text-white',
'dark:shadow-[0_0_0_0.5px_#000,0_0.5px_1px_rgb(0_0_0/0.45),0_1px_2px_rgb(0_0_0/0.35),inset_0_0.5px_0_rgb(255_255_255/0.14)]',
'dark:hover:[background-image:linear-gradient(rgb(255_255_255/0.08),rgb(255_255_255/0.08))]',
'dark:active:[background-image:linear-gradient(rgb(255_255_255/0.12),rgb(255_255_255/0.12))]',
'dark:focus-visible:shadow-[0_0_0_2px_rgb(255_255_255/0.4),0_0_0_0.5px_#000,0_0.5px_1px_rgb(0_0_0/0.45),0_1px_2px_rgb(0_0_0/0.35),inset_0_0.5px_0_rgb(255_255_255/0.14)]',
);
/**
* Joined capsule — one glass pill holding several segments (zoom −/+) with a
* hairline divider, like iOS grouped controls. The pill owns the glass
* chrome; segments (see `controlSegmentClass`) become flat, full-height hit
* areas.
*/
const controlGroupClass = cn(
'inline-flex items-stretch overflow-hidden rounded-full',
'border border-black/20 bg-white/55',
'shadow-[0_0.5px_1px_rgb(0_0_0/0.12),0_1px_2px_rgb(0_0_0/0.08),inset_0_0.5px_0_rgb(255_255_255/0.65)]',
'backdrop-blur-[40px] backdrop-saturate-150',
'dark:border-white/25 dark:bg-neutral-900/45',
'dark:shadow-[0_0_0_0.5px_#000,0_0.5px_1px_rgb(0_0_0/0.45),0_1px_2px_rgb(0_0_0/0.35),inset_0_0.5px_0_rgb(255_255_255/0.14)]',
);
/**
* Segment inside `controlGroupClass`: sheds its own glass chrome (the pill
* owns it), doesn't shrink on press (the pill is one rigid control), and uses
* an inset focus ring (the pill clips outset rings). Each segment is as wide
* as the pill is tall → a two-segment capsule is exactly 2:1.
*/
const controlSegmentClass = cn(
'relative h-auto w-9 rounded-none border-0 bg-transparent shadow-none',
'backdrop-blur-none backdrop-saturate-100',
'active:scale-100',
'focus-visible:shadow-[inset_0_0_0_2px_rgb(0_0_0/0.25)]',
'dark:bg-transparent dark:shadow-none',
'dark:focus-visible:shadow-[inset_0_0_0_2px_rgb(255_255_255/0.4)]',
);
/** Hairline divider on the leading edge of a follow-up capsule segment. */
const controlSegmentDividerClass =
'before:absolute before:inset-y-[22%] before:start-0 before:w-px before:bg-black/15 dark:before:bg-white/20';
/**
* Chrome that gets out of the way during gestures. `visibility` (not just
* opacity/pointer-events) keeps hidden controls out of the tab order; its 0s
* flip is delayed to the end of the fade-out, instant on fade-in. Pull-to-
* dismiss (and the dismiss animation) hides all gesture chrome; `starting:`
* fades it in on open.
*/
const chromeGestureHideClass = cn(
'visible opacity-100 [transition:opacity_200ms,visibility_0s_linear_0s] starting:opacity-0',
'group-data-pulling/lb:invisible group-data-pulling/lb:opacity-0',
'group-data-pulling/lb:[transition:opacity_200ms,visibility_0s_linear_200ms]',
'group-data-pull-dismissing/lb:invisible group-data-pull-dismissing/lb:opacity-0',
'group-data-pull-dismissing/lb:[transition:opacity_200ms,visibility_0s_linear_200ms]',
);
/* ── Styled primitives (design-system re-exports) ───────────────────────── */
function Root(props: React.ComponentProps<typeof RamkaLightbox.Root>) {
return <RamkaLightbox.Root {...props} />;
}
function Trigger({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Trigger>) {
return (
<RamkaLightbox.Trigger
data-slot="lightbox-trigger"
className={cn(
'cursor-pointer outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500',
className,
)}
{...props}
/>
);
}
function Portal({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Portal>) {
return (
<RamkaLightbox.Portal
data-slot="lightbox-portal"
className={cn('[--lb-ease-out-expo:cubic-bezier(0.16,1,0.3,1)]', className)}
{...props}
/>
);
}
function Backdrop({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Backdrop>) {
return (
<RamkaLightbox.Backdrop
data-slot="lightbox-backdrop"
className={cn(
// Solid — the page underneath is fully covered while open.
'fixed inset-0 bg-white opacity-0 dark:bg-black',
// Duration cascade: `--lightbox-pull-snap-*` exist only while a
// cancelled pull-to-dismiss springs back (so the refade matches the
// item's spring); otherwise `--lightbox-vt-duration` — the Root's
// `viewTransition` preset timing, which the library always sets on
// the Portal host. The 360ms literal is just a safety net.
'transition-opacity',
'duration-[var(--lightbox-pull-snap-duration,var(--lightbox-vt-duration,360ms))]',
'ease-[var(--lightbox-pull-snap-easing,var(--lb-ease-out-expo))]',
// `--lightbox-pull-progress` is 0 → 1 while pull-to-dismiss drags;
// `starting:` fades from 0 on the first frame after [data-open].
'data-open:opacity-[calc(1_-_var(--lightbox-pull-progress,0)*0.6)]',
'data-open:starting:opacity-0',
'data-pulling:transition-none', // follow the finger
className,
)}
{...props}
/>
);
}
function Content({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Content>) {
return (
<RamkaLightbox.Content
data-slot="lightbox-content"
className={cn(
// `group/lb` publishes ramka's state attributes (data-pulling,
// data-pull-dismissing, data-zoomed) to all chrome below.
'group/lb fixed inset-0 flex flex-col overscroll-contain opacity-0 outline-none',
// Same preset clock as the backdrop and the morph.
'transition-opacity duration-[var(--lightbox-vt-duration,360ms)] ease-(--lb-ease-out-expo)',
'data-open:opacity-100 data-open:starting:opacity-0',
// Letterbox insets around the media (read by Slide padding and the
// snug width formula). Mobile: equal top/bottom so the media is
// vertically centered — 4rem clears the close-button row above and
// the caption + counter below. Desktop: roomier, and the bottom adds
// the thumbnail-strip row + one-line caption bar.
'[--lb-inset-top:calc(4rem_+_env(safe-area-inset-top,0px))]',
'[--lb-inset-bottom:calc(4rem_+_env(safe-area-inset-bottom,0px))]',
'[--lb-inset-inline:0px]',
'md:[--lb-inset-top:calc(4.5rem_+_env(safe-area-inset-top,0px))]',
'md:[--lb-inset-bottom:calc(7.5rem_+_env(safe-area-inset-bottom,0px))]',
'md:[--lb-inset-inline:3rem]',
className,
)}
{...props}
/>
);
}
function Close({ className, children, ...props }: React.ComponentProps<typeof RamkaLightbox.Close>) {
return (
<RamkaLightbox.Close
data-slot="lightbox-close"
// The ✕ glyph is optically lighter than the zoom glyphs — render larger.
className={cn(controlClass, '[&_svg]:size-4.5', className)}
aria-label="Close"
{...props}
>
{children ?? <XIcon strokeWidth={2.25} />}
</RamkaLightbox.Close>
);
}
function Slides({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Slides>) {
return (
<RamkaLightbox.Slides
data-slot="lightbox-slides"
className={cn(
'flex-1 gap-5 md:gap-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
{...props}
/>
);
}
function Slide({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Slide>) {
return (
<RamkaLightbox.Slide
data-slot="lightbox-slide"
className={cn(
'h-full min-w-full flex-[0_0_100%] px-(--lb-inset-inline) pt-(--lb-inset-top) pb-(--lb-inset-bottom)',
className,
)}
{...props}
/>
);
}
function Item({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Item>) {
return <RamkaLightbox.Item data-slot="lightbox-item" className={className} {...props} />;
}
function Media({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Media>) {
return (
<RamkaLightbox.Media
data-slot="lightbox-media"
className={cn('overflow-hidden md:rounded-lg [&_img]:select-none', className)}
{...props}
/>
);
}
function Zoom(props: React.ComponentProps<typeof RamkaLightbox.Zoom>) {
return <RamkaLightbox.Zoom {...props} />;
}
function ZoomIn({ className, children, ...props }: React.ComponentProps<typeof RamkaLightbox.ZoomIn>) {
return (
<RamkaLightbox.ZoomIn
data-slot="lightbox-zoom-in"
className={cn(controlClass, 'max-md:hidden', className)}
aria-label="Zoom in"
{...props}
>
{children ?? <ZoomInIcon strokeWidth={2.25} />}
</RamkaLightbox.ZoomIn>
);
}
function ZoomOut({ className, children, ...props }: React.ComponentProps<typeof RamkaLightbox.ZoomOut>) {
return (
<RamkaLightbox.ZoomOut
data-slot="lightbox-zoom-out"
className={cn(controlClass, 'max-md:hidden', className)}
aria-label="Zoom out"
{...props}
>
{children ?? <ZoomOutIcon strokeWidth={2.25} />}
</RamkaLightbox.ZoomOut>
);
}
function Counter({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Counter>) {
return (
<RamkaLightbox.Counter
data-slot="lightbox-counter"
// md+: the thumbnail strip shows position, so the counter collapses to
// sr-only and keeps only its aria-live announcement role.
className={cn(
'text-[0.6875rem] leading-[1.25] text-neutral-500 tabular-nums md:sr-only dark:text-neutral-400',
className,
)}
{...props}
/>
);
}
function Caption({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Caption>) {
return (
<RamkaLightbox.Caption
data-slot="lightbox-caption"
className={cn(
'pointer-events-auto block max-w-full truncate text-[0.8125rem] leading-[1.25] font-medium text-neutral-900 select-text dark:text-neutral-50',
className,
)}
{...props}
/>
);
}
function ThumbnailStrip({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.ThumbnailStrip>) {
return (
<RamkaLightbox.ThumbnailStrip
data-slot="lightbox-thumbnail-strip"
className={cn(
'[--lb-thumb-size:3.5rem] [--lb-strip-fade:32px]',
// Soft edge fade over the scrolling thumbs (eased ramp, not linear).
'[--lb-strip-mask:linear-gradient(to_right,rgb(0_0_0/0)_0,rgb(0_0_0/0.156)_calc(var(--lb-strip-fade)*0.25),rgb(0_0_0/0.5)_calc(var(--lb-strip-fade)*0.5),rgb(0_0_0/0.844)_calc(var(--lb-strip-fade)*0.75),#000_var(--lb-strip-fade),#000_calc(100%_-_var(--lb-strip-fade)),rgb(0_0_0/0.844)_calc(100%_-_var(--lb-strip-fade)*0.75),rgb(0_0_0/0.5)_calc(100%_-_var(--lb-strip-fade)*0.5),rgb(0_0_0/0.156)_calc(100%_-_var(--lb-strip-fade)*0.25),rgb(0_0_0/0)_100%)]',
// pointer-events-auto counteracts the bottom band's click-through.
'pointer-events-auto w-full min-w-0 self-stretch py-1',
'[-webkit-mask-image:var(--lb-strip-mask)] [mask-image:var(--lb-strip-mask)]',
className,
)}
{...props}
/>
);
}
function ThumbnailStripTrack({
className,
...props
}: React.ComponentProps<typeof RamkaLightbox.ThumbnailStripTrack>) {
return (
<RamkaLightbox.ThumbnailStripTrack
data-slot="lightbox-thumbnail-strip-track"
className={cn('flex w-max items-center gap-2', className)}
{...props}
/>
);
}
function Thumbnail({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Thumbnail>) {
return (
<RamkaLightbox.Thumbnail
data-slot="lightbox-thumbnail"
className={cn(
'relative block size-(--lb-thumb-size) shrink-0 cursor-pointer overflow-hidden rounded-md',
'[&_img]:block [&_img]:size-full [&_img]:object-cover',
className,
)}
{...props}
/>
);
}
function Previous(props: React.ComponentProps<typeof RamkaLightbox.Previous>) {
return <RamkaLightbox.Previous {...props} />;
}
function Next(props: React.ComponentProps<typeof RamkaLightbox.Next>) {
return <RamkaLightbox.Next {...props} />;
}
function ThumbnailGroup(props: React.ComponentProps<typeof RamkaLightbox.ThumbnailGroup>) {
return <RamkaLightbox.ThumbnailGroup {...props} />;
}
/* ── Snug layout (aspect-sized cards + peeking neighbors) ───────────────── */
/*
* Card width = the largest size that fits the letterbox at the item's aspect
* ratio: min(scrollport width minus gutters, letterbox height × ratio). The
* `--lb-snug-ratio*` values are per-item aspect ratios set inline by the
* Gallery; cqw/cqh resolve against the Slides scrollport (the library makes
* it a size container). -start/-end are the first/last item's ratios, needed
* by the ::before/::after spacers so the first/last card can center in the
* scrollport under scroll-snap.
*/
const snugSlidesClass = cn(
'[--lb-snug-gap:1.25rem] gap-(--lb-snug-gap) md:gap-(--lb-snug-gap)',
'[--lb-snug-width-start:min(calc(100cqw_-_2*var(--lb-inset-inline)),calc((100cqh_-_var(--lb-inset-top)_-_var(--lb-inset-bottom))*var(--lb-snug-ratio-start,1)))]',
'[--lb-snug-width-end:min(calc(100cqw_-_2*var(--lb-inset-inline)),calc((100cqh_-_var(--lb-inset-top)_-_var(--lb-inset-bottom))*var(--lb-snug-ratio-end,1)))]',
'md:before:pointer-events-none md:before:flex-[0_0_calc((100cqw_-_var(--lb-snug-width-start))/2_-_var(--lb-snug-gap))]',
'md:after:pointer-events-none md:after:flex-[0_0_calc((100cqw_-_var(--lb-snug-width-end))/2_-_var(--lb-snug-gap))]',
);
const snugSlideClass = cn(
// No horizontal padding on the card itself — the gutter lives in the width
// formula only, so neighbors can peek right up to the card edge. z-0 is the
// stacking baseline for the zoomed-card promotion below.
'z-0 px-0',
'[--lb-snug-width:min(calc(100cqw_-_2*var(--lb-inset-inline)),calc((100cqh_-_var(--lb-inset-top)_-_var(--lb-inset-bottom))*var(--lb-snug-ratio)))]',
'md:w-(--lb-snug-width) md:min-w-0 md:flex-[0_0_auto]',
// The zoomed active card paints above the peeking neighbors.
'group-data-zoomed/lb:has-data-active:z-[1]',
);
/*
* Neighbor cards dim as the active item is pulled to dismiss
* (`--lightbox-pull-progress`) or zoomed (`--lightbox-zoom-progress`) — both
* library-written, 0 → 1. `filter` tracks the gesture 1:1 (transitioning it
* would lag the finger); `opacity` transitions so the pull snap-back resolves
* on the same spring as the item.
*/
const snugItemClass = cn(
'transition-opacity',
'duration-[var(--lightbox-pull-snap-duration,500ms)]',
'ease-[var(--lightbox-pull-snap-easing,var(--lb-ease-out-expo))]',
'not-data-active:[--lb-neighbor-dim:max(var(--lightbox-pull-progress,0),var(--lightbox-zoom-progress,0))]',
'not-data-active:opacity-[calc(1_-_var(--lightbox-pull-progress,0)*0.85)]',
'not-data-active:[filter:opacity(calc(1_-_var(--lb-neighbor-dim)))_blur(calc(var(--lb-neighbor-dim)*6px))]',
'group-data-pulling/lb:transition-none', // follow the finger
);
/* ── Composed Gallery (product chrome) ──────────────────────────────────── */
function GalleryThumbnailStrip({ items }: { items: LightboxItem[] }) {
if (items.length <= 1) return null;
return (
// The strip is desktop-only; mobile shows the counter instead.
<ThumbnailStrip className="max-md:hidden">
<ThumbnailStripTrack aria-label="Photo thumbnails">
{items.map((item, i) => (
<Thumbnail key={item.id ?? i} index={i}>
<img src={item.thumb} alt={item.alt} draggable={false} />
</Thumbnail>
))}
</ThumbnailStripTrack>
{/* Decorative ring over the active tab — the track scrolls under it. */}
<span
aria-hidden
className="pointer-events-none absolute top-1/2 left-1/2 size-[calc(var(--lb-thumb-size)_+_8px)] -translate-x-1/2 -translate-y-1/2 rounded-[calc(0.375rem_+_2px)] border-2 border-black dark:border-white"
/>
</ThumbnailStrip>
);
}
/**
* Default product chrome — Portal → Backdrop + Content → Slides/Zoom + glass
* controls / caption + counter placard / desktop ThumbnailStrip. Supports bleed (full-width)
* and snug (aspect-sized cards) via the top-right layout toggle or `slidesLayout`.
*/
function Gallery({
items,
ariaLabel,
slidesLayout: slidesLayoutProp,
onSlidesLayoutChange,
}: {
items: LightboxItem[];
ariaLabel: string;
slidesLayout?: SlidesLayout;
onSlidesLayoutChange?: (layout: SlidesLayout) => void;
}) {
const [slidesLayoutInternal, setSlidesLayoutInternal] = React.useState<SlidesLayout>(
() => readStoredSlidesLayout() ?? 'bleed',
);
const slidesLayout = slidesLayoutProp ?? slidesLayoutInternal;
const snug = slidesLayout === 'snug';
// Single item → no thumbnail strip → solo layout (tighter, centered bottom band).
const solo = items.length <= 1;
const setSlidesLayout = React.useCallback(
(next: SlidesLayout) => {
storeSlidesLayout(next);
onSlidesLayoutChange?.(next);
if (slidesLayoutProp === undefined) {
setSlidesLayoutInternal(next);
}
},
[onSlidesLayoutChange, slidesLayoutProp],
);
const toggleSlidesLayout = React.useCallback(() => {
setSlidesLayout(slidesLayout === 'snug' ? 'bleed' : 'snug');
}, [setSlidesLayout, slidesLayout]);
const snugEdgeRatios =
snug && items.length > 0
? {
start: itemAspectRatio(items[0]!),
end: itemAspectRatio(items[items.length - 1]!),
}
: null;
return (
<Portal>
<Backdrop />
<Content
aria-label={ariaLabel}
className={cn(
// Snug: the horizontal inset is a scrollport gutter consumed by the
// card width formula — not per-slide padding.
snug && 'md:[--lb-inset-inline:2rem]',
// Solo (single item — no thumbnail strip): only the caption placard
// sits below, so match the top inset and keep the letterbox symmetric.
solo && 'md:[--lb-inset-bottom:calc(4.5rem_+_env(safe-area-inset-bottom,0px))]',
)}
>
<Slides
key={slidesLayout}
aria-label="Full-size images"
// The library ships no copy, so the words announcing the carousel and
// its slides live here, next to the rest of your UI strings.
aria-roledescription="carousel"
preload={2}
className={snug ? snugSlidesClass : undefined}
style={
snugEdgeRatios
? ({
'--lb-snug-ratio-start': snugEdgeRatios.start,
'--lb-snug-ratio-end': snugEdgeRatios.end,
} as React.CSSProperties)
: undefined
}
>
{items.map((item, i) => {
const ratio = itemAspectRatio(item);
return (
<Slide
key={item.id ?? i}
className={snug ? snugSlideClass : undefined}
style={snug ? ({ '--lb-snug-ratio': ratio } as React.CSSProperties) : undefined}
>
<Item
index={i}
caption={item.caption ?? item.alt}
className={snug ? snugItemClass : undefined}
aria-roledescription="slide"
// With the strip the item is a `tabpanel` its thumbnail tab
// already names, so only the solo shape needs a name here.
{...(solo ? { 'aria-label': `${i + 1} of ${items.length}` } : {})}
>
<Zoom maxZoom={6}>
<Media width={item.width} height={item.height}>
<img
width={item.width}
height={item.height}
src={item.src}
alt={item.alt}
loading="eager"
draggable={false}
/>
</Media>
</Zoom>
</Item>
</Slide>
);
})}
</Slides>
<div
className={cn(
'absolute top-[calc(0.75rem_+_env(safe-area-inset-top,0px))] end-3 z-[1] flex gap-2',
chromeGestureHideClass,
// On mobile, zoom also hides the top controls — the media fills the screen.
'max-md:group-data-zoomed/lb:invisible max-md:group-data-zoomed/lb:opacity-0',
'max-md:group-data-zoomed/lb:[transition:opacity_200ms,visibility_0s_linear_200ms]',
)}
>
{/* Layout toggle: the icon is a miniature of the CURRENT layout —
snug shows a card with neighbours, bleed one wide slide. */}
<button
type="button"
className={cn(controlClass, 'max-md:hidden [&_svg]:size-4.5')}
aria-pressed={snug}
aria-label={snug ? 'Switch to full-bleed slides' : 'Switch to snug slides'}
onClick={toggleSlidesLayout}
>
{snug ? (
<GalleryHorizontalIcon strokeWidth={2.25} />
) : (
<RectangleHorizontalIcon strokeWidth={2.25} />
)}
</button>
{/* Zoom −/+ share one capsule with a hairline divider. */}
<div className={cn(controlGroupClass, 'max-md:hidden')}>
<ZoomOut className={controlSegmentClass} />
<ZoomIn className={cn(controlSegmentClass, controlSegmentDividerClass)} />
</div>
<Close />
</div>
<div
className={cn(
// Click-through the inset band; caption opts back in for text selection.
'pointer-events-none absolute inset-x-0 bottom-0 z-[1] flex flex-col items-center gap-3',
'px-3 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))]',
chromeGestureHideClass,
// Zoom hides the bottom placard (the zoomed media pans under it).
'group-data-zoomed/lb:invisible group-data-zoomed/lb:opacity-0',
'group-data-zoomed/lb:[transition:opacity_200ms,visibility_0s_linear_200ms]',
// Solo (no strip): center the placard in the bottom inset band
// instead of pinning it to the viewport edge.
solo && 'min-h-(--lb-inset-bottom) justify-center pb-[env(safe-area-inset-bottom,0px)]',
)}
>
{/*
Caption under the media, placard-style. The counter is visible on
mobile where there's no strip to show position; on md+ the strip
covers that, so the counter collapses to sr-only and keeps only
its aria-live announcement role.
*/}
<div className="flex max-w-full flex-col items-center text-center">
<Caption />
{/* "1 of 1" is noise — skip the counter for single-item galleries. */}
{solo ? null : <Counter>{({ current, total }) => `${current} of ${total}`}</Counter>}
</div>
<GalleryThumbnailStrip items={items} />
</div>
</Content>
</Portal>
);
}
/**
* Styled Lightbox namespace — drop-in replacement for `import * as Lightbox from '@ramka/react/lightbox'`
* with this preset’s styles and composed Gallery.
*/
export const Lightbox = {
Root,
Trigger,
Portal,
Backdrop,
Content,
Close,
Slides,
Slide,
Item,
Media,
Zoom,
ZoomIn,
ZoomOut,
Counter,
Caption,
ThumbnailStrip,
ThumbnailStripTrack,
Thumbnail,
ThumbnailGroup,
Previous,
Next,
Gallery,
};