Store

A full commerce viewer: the media stage (vertical thumbnail rail, pager, corner zoom) sits beside a buy panel with price, highlights, and the add-to-cart CTA — on mobile the panel collapses into a sticky price + CTA bar, so the shopper never loses the buy button while inspecting photos. Copy the Package files (lightbox.tsx + lightbox.css) into your design system, then wire triggers in your app. The Example tab shows a product card whose thumbnails swap the main image, viewer navigation synced back so the close morph lands on the visible image, and add-to-cart state shared between the card and the viewer.

See also: Lightbox overview

New colour

Terra 27L Backpack

$149$189

Waxed canvas, full-grain leather straps, and a padded 16″ laptop sleeve. Weather-ready for the daily commute or a weekend on the trail.

'use client';

/**
 * Store lightbox — design-system package entry (copy with lightbox.css).
 *
 * A full commerce viewer, not just a photo overlay: the media stage keeps a
 * vertical thumbnail rail (desktop) with a bottom ‹ 2 / 5 › pagination
 * cluster and corner zoom controls, while a buy panel stays alongside —
 * product name, price (with compare-at), description, highlights, stock note
 * and the add-to-cart CTA. Mobile swaps the panel for a sticky price + CTA
 * bar under the media. The shopper never loses the buy button while
 * inspecting photos.
 */

import * as React from 'react';
import * as RamkaLightbox from '@ramka/react/lightbox';

import './lightbox.css';

function cx(...parts: Array<string | false | null | undefined>) {
  return parts.filter(Boolean).join(' ');
}

/* ── Icons (inline — no icon library dependency) ─────────────────────────── */

function IconX() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
      <path d="M18 6 6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconChevronLeft() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
      <path d="m15 18-6-6 6-6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconChevronRight() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
      <path d="m9 18 6-6-6-6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconZoomIn() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
      <circle cx="11" cy="11" r="8" />
      <path d="m21 21-4.3-4.3M11 8v6M8 11h6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconZoomOut() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
      <circle cx="11" cy="11" r="8" />
      <path d="m21 21-4.3-4.3M8 11h6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ── Public types ────────────────────────────────────────────────────────── */

/** Item shape for `Lightbox.Gallery` — pass real intrinsic pixel size. */
export type LightboxItem = {
  id?: string | number;
  src: string;
  thumb: string;
  alt: string;
  width: number;
  height: number;
};

/** Product data rendered by the buy panel (desktop) and mobile bar. */
export type ProductInfo = {
  name: string;
  price: string;
  /** Original price, rendered struck-through next to `price`. */
  compareAtPrice?: string;
  /** Short label above the name, e.g. “New colour” or “Bestseller”. */
  badge?: string;
  description?: string;
  /** Bullet points under the description. */
  highlights?: string[];
  /** Availability line above the CTA, e.g. “In stock — ships in 2 days”. */
  stockNote?: string;
};

/* ── 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 className={cx('slb-trigger', className)} {...props} />;
}

function Portal({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Portal>) {
  return <RamkaLightbox.Portal className={cx('slb-portal', className)} {...props} />;
}

function Backdrop({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Backdrop>) {
  return <RamkaLightbox.Backdrop className={cx('slb-backdrop', className)} {...props} />;
}

function Content({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Content>) {
  return <RamkaLightbox.Content className={cx('slb-content', className)} {...props} />;
}

function Stage({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Stage>) {
  return <RamkaLightbox.Stage className={cx('slb-stage', className)} {...props} />;
}

function Close({ className, children, ...props }: React.ComponentProps<typeof RamkaLightbox.Close>) {
  return (
    <RamkaLightbox.Close className={cx('slb-control', 'slb-close', className)} aria-label="Close" {...props}>
      {children ?? <IconX />}
    </RamkaLightbox.Close>
  );
}

function Slides({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Slides>) {
  return <RamkaLightbox.Slides className={cx('slb-slides', className)} {...props} />;
}

function Slide({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Slide>) {
  return <RamkaLightbox.Slide className={cx('slb-slide', className)} {...props} />;
}

function Item(props: React.ComponentProps<typeof RamkaLightbox.Item>) {
  return <RamkaLightbox.Item {...props} />;
}

function Media({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Media>) {
  return <RamkaLightbox.Media className={cx('slb-media', 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 className={cx('slb-control', className)} aria-label="Zoom in" {...props}>
      {children ?? <IconZoomIn />}
    </RamkaLightbox.ZoomIn>
  );
}

function ZoomOut({ className, children, ...props }: React.ComponentProps<typeof RamkaLightbox.ZoomOut>) {
  return (
    <RamkaLightbox.ZoomOut className={cx('slb-control', className)} aria-label="Zoom out" {...props}>
      {children ?? <IconZoomOut />}
    </RamkaLightbox.ZoomOut>
  );
}

function Counter({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Counter>) {
  return <RamkaLightbox.Counter className={cx('slb-counter', className)} {...props} />;
}

function Previous({ className, children, ...props }: React.ComponentProps<typeof RamkaLightbox.Previous>) {
  return (
    <RamkaLightbox.Previous
      className={cx('slb-control', 'slb-nav', className)}
      aria-label="Previous"
      {...props}
    >
      {children ?? <IconChevronLeft />}
    </RamkaLightbox.Previous>
  );
}

function Next({ className, children, ...props }: React.ComponentProps<typeof RamkaLightbox.Next>) {
  return (
    <RamkaLightbox.Next
      className={cx('slb-control', 'slb-nav', className)}
      aria-label="Next"
      {...props}
    >
      {children ?? <IconChevronRight />}
    </RamkaLightbox.Next>
  );
}

function Thumbnail(props: React.ComponentProps<typeof RamkaLightbox.Thumbnail>) {
  return <RamkaLightbox.Thumbnail {...props} />;
}

function ThumbnailGroup(props: React.ComponentProps<typeof RamkaLightbox.ThumbnailGroup>) {
  return <RamkaLightbox.ThumbnailGroup {...props} />;
}

/* ── Buy panel pieces ────────────────────────────────────────────────────── */

function PriceRow({ product, compact }: { product: ProductInfo; compact?: boolean }) {
  return (
    <span className={compact ? 'slb-mobile-price' : 'slb-price-row'}>
      <span className="slb-price">{product.price}</span>
      {product.compareAtPrice ? <s className="slb-compare-at">{product.compareAtPrice}</s> : null}
    </span>
  );
}

/* ── Composed Gallery (commerce viewer) ─────────────────────────────────── */

/**
 * Store commerce viewer — Portal → Backdrop + Content → Stage (slides,
 * vertical thumbnail rail, ‹ 2 / 5 › cluster, corner zoom, close) beside a
 * buy panel (desktop) / above a sticky price + CTA bar (mobile).
 *
 * The rail is a plain `ThumbnailGroup` tablist (not the scroll-linked
 * ThumbnailStrip): PDP galleries have a handful of images, so all thumbs are
 * visible and arrow keys / clicks jump directly.
 *
 * Pass `panel` to replace the default panel body with your own composition
 * (variant pickers, size selectors, reviews) — the package chrome stays.
 */
function Gallery({
  items,
  product,
  ariaLabel,
  ctaLabel = 'Add to bag',
  onAddToCart,
  panel,
}: {
  items: LightboxItem[];
  product: ProductInfo;
  ariaLabel: string;
  ctaLabel?: string;
  onAddToCart?: () => void;
  panel?: React.ReactNode;
}) {
  const multiple = items.length > 1;

  return (
    <Portal>
      <Backdrop />
      <Content aria-label={ariaLabel}>
        <Stage>
          <Slides
            aria-label="Product 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}
          >
            {items.map((item, i) => (
              <Slide key={item.id ?? i}>
                <Item
                  index={i}
                  aria-roledescription="slide"
                  // With the rail the item is a `tabpanel` its thumbnail tab
                  // already names, so only the single-image shape needs a name.
                  {...(multiple ? {} : { 'aria-label': `${i + 1} of ${items.length}` })}
                >
                  <Zoom maxZoom={5}>
                    <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>

          <Close className="slb-chrome-gesture-hide" />

          {multiple ? (
            <ThumbnailGroup className={cx('slb-rail', 'slb-chrome-gesture-hide')} aria-label="Product images">
              {items.map((item, i) => (
                <Thumbnail key={item.id ?? i} index={i} className="slb-rail-thumb">
                  <img src={item.thumb} alt={item.alt} draggable={false} />
                </Thumbnail>
              ))}
            </ThumbnailGroup>
          ) : null}

          {multiple ? (
            <div className={cx('slb-pager', 'slb-chrome-gesture-hide')}>
              <Previous />
              <Counter>{({ current, total }) => `${current} / ${total}`}</Counter>
              <Next />
            </div>
          ) : null}

          <div className={cx('slb-zoom-group', 'slb-chrome-gesture-hide')}>
            <ZoomOut />
            <ZoomIn />
          </div>
        </Stage>

        {/* The buy context (panel / mobile bar) stays put during pull-to-dismiss —
            only the stage chrome fades. */}
        <aside className="slb-panel" aria-label="Product details">
          {panel ?? (
            <>
              <div className="slb-panel-body">
                {product.badge ? <span className="slb-badge">{product.badge}</span> : null}
                <h2 className="slb-name">{product.name}</h2>
                <PriceRow product={product} />
                {product.description ? <p className="slb-description">{product.description}</p> : null}
                {product.highlights?.length ? (
                  <ul className="slb-highlights">
                    {product.highlights.map((line) => (
                      <li key={line}>{line}</li>
                    ))}
                  </ul>
                ) : null}
              </div>
              <div className="slb-panel-footer">
                {product.stockNote ? <p className="slb-stock">{product.stockNote}</p> : null}
                <button type="button" className="slb-cta" onClick={onAddToCart}>
                  {ctaLabel}
                </button>
              </div>
            </>
          )}
        </aside>

        {/* Mobile: the buy context rides along under the media. */}
        <div className="slb-mobile-bar">
          <div className="slb-mobile-info">
            <span className="slb-mobile-name">{product.name}</span>
            <PriceRow product={product} compact />
          </div>
          <button type="button" className={cx('slb-cta', 'slb-cta-compact')} onClick={onAddToCart}>
            {ctaLabel}
          </button>
        </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,
  Stage,
  Close,
  Slides,
  Slide,
  Item,
  Media,
  Zoom,
  ZoomIn,
  ZoomOut,
  Counter,
  Previous,
  Next,
  Thumbnail,
  ThumbnailGroup,
  Gallery,
};