Stories

Instagram-style stories: a horizontal row of avatars, each dissolving into a vertical video via crossfade="both" (the circle is not the 9:16 frame, so a geometry morph would look wrong). Inside the viewer, slides are a native snap scroller with a CSS view-timeline rotateY — cards hinge on their leading edge as they enter and on their trailing edge as they leave. Copy the Package files (lightbox.tsx + lightbox.css) into your design system, then wire triggers in your app.

See also: Lightbox overview

Stories

  • Maya
  • Julian
  • Noor
  • Eli
  • Sam
  • Priya
  • Tomás
  • Aisha
'use client';

/**
 * Stories lightbox — design-system package entry (copy with lightbox.css).
 *
 * Instagram-style vertical stories: circular avatar triggers dissolve into a
 * 9:16 card via `crossfade`, then swipe between stories with a CSS
 * scroll-driven rotateY (view-timeline) page-turn. No zoom — swipe / arrows
 * to browse, pull down to dismiss.
 */

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

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={1.75} 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={1.75} 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={1.75} aria-hidden>
      <path d="m9 18 6-6-6-6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

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

/** Item shape for `Lightbox.Gallery` — pass real intrinsic pixel size. */
export type LightboxItem = {
  id?: string | number;
  name: string;
  avatar: string;
  video: string;
  poster: string;
  alt: string;
  /** Relative time shown in the story HUD, e.g. “2h”. */
  time?: string;
  width: number;
  height: number;
};

/* ── Styled primitives (design-system re-exports) ───────────────────────── */

function pauseDialogVideos(scope: Element | null) {
  if (!scope) return;
  for (const video of scope.querySelectorAll('video')) {
    video.pause();
  }
}

function Root({
  onBeforeClose,
  ...props
}: React.ComponentProps<typeof RamkaLightbox.Root>) {
  return (
    <RamkaLightbox.Root
      {...props}
      onBeforeClose={(ctx) => {
        pauseDialogVideos(
          ctx.destination?.closest('[data-ramka-content]') ?? ctx.destination,
        );
        onBeforeClose?.(ctx);
      }}
    />
  );
}

function Trigger({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Trigger>) {
  return <RamkaLightbox.Trigger className={cx('stl-trigger', className)} {...props} />;
}

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

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

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

function Stage({
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof RamkaLightbox.Stage>, 'children'> & {
  children?: React.ReactNode;
}) {
  return (
    <RamkaLightbox.Stage className={cx('stl-stage', className)} {...props}>
      {({ morphRef }) => (
        <div ref={morphRef} className="stl-frame">
          {children}
        </div>
      )}
    </RamkaLightbox.Stage>
  );
}

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

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

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

function Item({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Item>) {
  return <RamkaLightbox.Item className={cx('stl-item', className)} {...props} />;
}

function Media({ className, ...props }: React.ComponentProps<typeof RamkaLightbox.Media>) {
  return <RamkaLightbox.Media className={cx('stl-media', className)} {...props} />;
}

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

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

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

/* ── Video (plays while this slide is active; pause in place when not) ─── */

function StoryVideo({
  src,
  poster,
  alt,
  width,
  height,
  active,
}: {
  src: string;
  poster: string;
  alt: string;
  width: number;
  height: number;
  active: boolean;
}) {
  const { open, dialogElementRef } = useLightboxContext();
  const videoRef = React.useRef<HTMLVideoElement>(null);

  const syncPlayback = React.useCallback(() => {
    const el = videoRef.current;
    if (!el) return;
    const content = dialogElementRef.current;
    const pulling =
      !!content &&
      (content.hasAttribute('data-pulling') || content.hasAttribute('data-pull-dismissing'));
    if (active && open && !pulling) {
      el.play().catch(() => {});
    } else {
      el.pause();
    }
  }, [active, open, dialogElementRef]);

  React.useEffect(() => {
    syncPlayback();
  }, [syncPlayback]);

  React.useEffect(() => {
    const content = dialogElementRef.current;
    if (!content) return;
    const mo = new MutationObserver(syncPlayback);
    mo.observe(content, {
      attributes: true,
      attributeFilter: ['data-pulling', 'data-pull-dismissing'],
    });
    return () => mo.disconnect();
  }, [dialogElementRef, syncPlayback]);

  return (
    <video
      ref={videoRef}
      src={src}
      poster={poster}
      width={width}
      height={height}
      playsInline
      muted
      loop
      preload={active ? 'auto' : 'metadata'}
      aria-label={alt}
      className="stl-video"
    />
  );
}

/* ── Composed Gallery ───────────────────────────────────────────────────── */

function StoryHud({ item, index, total }: { item: LightboxItem; index: number; total: number }) {
  return (
    <div className="stl-hud">
      <div className="stl-progress" aria-hidden>
        {Array.from({ length: total }, (_, j) => (
          <span
            key={j}
            className="stl-progress-seg"
            data-state={j === index ? 'current' : j < index ? 'past' : 'future'}
          />
        ))}
      </div>
      <div className="stl-meta">
        <img src={item.avatar} alt="" className="stl-meta-avatar" draggable={false} />
        <div className="stl-meta-text">
          <span className="stl-meta-name">{item.name}</span>
          {item.time ? <span className="stl-meta-time">{item.time}</span> : null}
        </div>
        <Close />
      </div>
    </div>
  );
}

function Gallery({ items, ariaLabel }: { items: LightboxItem[]; ariaLabel: string }) {
  const multiple = items.length > 1;

  return (
    <Portal>
      <Backdrop />
      <Content aria-label={ariaLabel}>
        {multiple ? <Previous className="stl-chrome-gesture-hide" /> : null}
        <Stage>
          <Slides
            aria-label="Stories"
            // 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={1}
          >
            {items.map((item, i) => (
              <Slide key={item.id ?? i}>
                <Item
                  index={i}
                  aria-roledescription="slide"
                  aria-label={`${i + 1} of ${items.length}`}
                >
                  {({ active }) => (
                    <Media
                      width={item.width}
                      height={item.height}
                      className="stl-card"
                      style={{
                        backgroundImage: `url(${item.poster})`,
                        backgroundSize: 'cover',
                        backgroundPosition: 'center',
                      }}
                    >
                      <StoryVideo
                        src={item.video}
                        poster={item.poster}
                        alt={item.alt}
                        width={item.width}
                        height={item.height}
                        active={active}
                      />
                      <StoryHud item={item} index={i} total={items.length} />
                    </Media>
                  )}
                </Item>
              </Slide>
            ))}
          </Slides>
        </Stage>
        {multiple ? (
          <>
            <Next className="stl-chrome-gesture-hide" />
            <Counter className="stl-chrome-gesture-hide" />
          </>
        ) : null}
      </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,
  Previous,
  Next,
  Counter,
  Gallery,
};