ui

Lightbox

Lightbox primitive.

Installation

pnpm dlx shadcn@latest add https://registry.circle.health/r/lightbox.json

Component

"use client";

import * as React from "react";

import { cn } from "@/registry/berlin/lib/utils";

type VideoContextValue = {
  isMuted: boolean;
  isPlaying: boolean;
  toggleMute: () => void;
  togglePlay: () => void;
};

const VideoContext = React.createContext<VideoContextValue | null>(null);

function useVideoControls() {
  const context = React.useContext(VideoContext);
  if (!context) {
    throw new Error("Video controls must be used within Video.");
  }
  return context;
}

function parseTextContent(text: unknown): React.ReactNode {
  if (!text) return null;
  if (typeof text === "string") return text;
  if (React.isValidElement(text)) return text;
  if (Array.isArray(text)) {
    return text
      .map((item) => {
        if (typeof item === "string") return item;
        if (item && typeof item === "object" && "children" in item && Array.isArray((item as { children?: unknown[] }).children)) {
          return (item as { children: Array<{ text?: string }> }).children.map((child) => child.text ?? "").join("");
        }
        return "";
      })
      .join(" ");
  }
  return String(text);
}

function positionClasses(position: "top left" | "bottom right" | "center" = "bottom right") {
  if (position === "top left") return "left-4 top-4";
  if (position === "center") return "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2";
  return "bottom-4 right-4";
}

function PlayIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="12" cy="12" r="11" stroke="currentColor" strokeWidth="2" />
      <path d="m10 8 6 4-6 4V8Z" fill="currentColor" />
    </svg>
  );
}

function PauseIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="12" cy="12" r="11" stroke="currentColor" strokeWidth="2" />
      <path d="M9 8h2v8H9zm4 0h2v8h-2z" fill="currentColor" />
    </svg>
  );
}

function VolumeIcon({ className, muted }: { className?: string; muted: boolean }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 9h4l5-4v14l-5-4H5V9Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
      {muted ? (
        <path d="m17 9 4 6m0-6-4 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      ) : (
        <path d="M18 9c1.333 1 2 2 2 3s-.667 2-2 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      )}
    </svg>
  );
}

function StarIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
      <path d="m8 1.5 1.9 3.86 4.26.62-3.08 3 0.72 4.25L8 11.2l-3.8 2 0.72-4.25-3.08-3 4.26-.62L8 1.5Z" />
    </svg>
  );
}

export interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
  src: string;
  config?: {
    autoPlay?: boolean;
    muted?: boolean;
    loop?: boolean;
  };
}

type PositionedControlProps = React.PropsWithChildren<{
  className?: string;
  position?: "top left" | "bottom right" | "center";
}>;

function PlayControl({ className, position = "bottom right" }: PositionedControlProps) {
  const { isPlaying, togglePlay } = useVideoControls();
  return (
    <button
      type="button"
      aria-label={isPlaying ? "Pause video" : "Play video"}
      onClick={togglePlay}
      className={cn(
        "absolute inline-flex size-12 items-center justify-center rounded-full border border-white/20 bg-black/45 text-white backdrop-blur-sm",
        positionClasses(position),
        className
      )}
    >
      {isPlaying ? <PauseIcon className="size-6" /> : <PlayIcon className="size-6" />}
    </button>
  );
}

function MuteControl({ className, position = "top left" }: PositionedControlProps) {
  const { isMuted, toggleMute } = useVideoControls();
  return (
    <button
      type="button"
      aria-label={isMuted ? "Unmute video" : "Mute video"}
      onClick={toggleMute}
      className={cn(
        "absolute inline-flex size-10 items-center justify-center rounded-full border border-white/20 bg-black/45 text-white backdrop-blur-sm",
        positionClasses(position),
        className
      )}
    >
      <VolumeIcon muted={isMuted} className="size-5" />
    </button>
  );
}

function PlayWithText({
  className,
  position = "center",
  children
}: PositionedControlProps) {
  const { isPlaying, togglePlay } = useVideoControls();
  return (
    <button
      type="button"
      aria-label={isPlaying ? "Pause video" : "Play video"}
      onClick={togglePlay}
      className={cn(
        "absolute inline-flex items-center gap-2 rounded-full border border-white/20 bg-black/55 px-5 py-3 text-sm font-medium text-white backdrop-blur-sm",
        positionClasses(position),
        className
      )}
    >
      {isPlaying ? <PauseIcon className="size-5" /> : <PlayIcon className="size-5" />}
      <span>{children}</span>
    </button>
  );
}

type VideoComponent = React.FC<VideoProps> & {
  Mute: typeof MuteControl;
  Play: typeof PlayControl;
  PlayWithText: typeof PlayWithText;
};

const VideoBase: React.FC<VideoProps> = ({ src, children, className, config, poster, ...props }) => {
  const videoRef = React.useRef<HTMLVideoElement>(null);
  const [isPlaying, setIsPlaying] = React.useState(Boolean(config?.autoPlay));
  const [isMuted, setIsMuted] = React.useState(Boolean(config?.muted));

  const togglePlay = React.useCallback(() => {
    const node = videoRef.current;
    if (!node) return;
    if (node.paused) {
      void node.play();
    } else {
      node.pause();
    }
  }, []);

  const toggleMute = React.useCallback(() => {
    const node = videoRef.current;
    if (!node) return;
    node.muted = !node.muted;
    setIsMuted(node.muted);
  }, []);

  React.useEffect(() => {
    const node = videoRef.current;
    if (!node) return;
    node.muted = Boolean(config?.muted);
    setIsMuted(Boolean(config?.muted));
  }, [config?.muted]);

  return (
    <VideoContext.Provider value={{ isMuted, isPlaying, toggleMute, togglePlay }}>
      <div className={cn("relative overflow-hidden rounded-[20px] bg-black", className)}>
        <video
          ref={videoRef}
          className="block h-full w-full object-cover"
          poster={poster}
          playsInline
          preload="metadata"
          onPlay={() => setIsPlaying(true)}
          onPause={() => setIsPlaying(false)}
          autoPlay={config?.autoPlay}
          muted={config?.muted}
          loop={config?.loop}
          {...props}
        >
          <source src={src} />
        </video>
        {children ?? (
          <div className="pointer-events-none absolute inset-0">
            <div className="pointer-events-auto">
              <PlayControl />
              <MuteControl />
            </div>
          </div>
        )}
      </div>
    </VideoContext.Provider>
  );
};

export const Video = VideoBase as VideoComponent;
Video.Play = PlayControl;
Video.Mute = MuteControl;
Video.PlayWithText = PlayWithText;

export interface MediaSlideProps {
  type: "image" | "video";
  src: string;
  text?: unknown;
  poster?: string;
  altText?: string;
  desktopWidth?: number;
  label?: string;
  className?: string;
}

export function MediaSlide({
  type,
  src,
  text,
  poster,
  altText,
  label,
  className
}: MediaSlideProps) {
  const textContent = parseTextContent(text);

  return (
    <div className={cn("relative h-full min-h-[320px] w-full overflow-hidden rounded-[24px] bg-card", className)}>
      {type === "video" ? (
        <Video src={src} poster={poster} className="h-full min-h-[320px] rounded-[24px]" />
      ) : (
        <img src={src} alt={altText || "Media"} className="h-full min-h-[320px] w-full object-cover" />
      )}
      {(label || textContent) ? (
        <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent p-5 text-white">
          {label ? <div className="text-xs font-semibold uppercase tracking-[0.18em] text-white/75">{label}</div> : null}
          {textContent ? <div className="mt-2 text-base font-medium leading-6">{textContent}</div> : null}
        </div>
      ) : null}
    </div>
  );
}

export interface ImageLightboxProps {
  src: string;
  children: React.ReactNode;
  alt?: string;
}

export interface LightboxProps {
  image: string;
  alt: string;
  onClose: () => void;
}

export function Lightbox({ image, alt, onClose }: LightboxProps) {
  React.useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") onClose();
    }
    document.body.style.overflow = "hidden";
    window.addEventListener("keydown", onKeyDown);
    return () => {
      document.body.style.overflow = "";
      window.removeEventListener("keydown", onKeyDown);
    };
  }, [onClose]);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-6" onClick={onClose}>
      <button
        type="button"
        aria-label="Close lightbox"
        className="absolute right-6 top-6 inline-flex size-10 items-center justify-center rounded-full border border-white/20 bg-black/40 text-white"
        onClick={onClose}
      >
        <span className="text-xl leading-none">Ɨ</span>
      </button>
      <img src={image} alt={alt} className="max-h-full max-w-full rounded-2xl object-contain" onClick={(event) => event.stopPropagation()} />
    </div>
  );
}

export function ImageWithLightbox({
  src,
  alt = "",
  width,
  height
}: {
  src: string;
  alt?: string;
  width: number;
  height: number;
}) {
  return (
    <ImageLightbox src={src} alt={alt}>
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        className="max-w-full cursor-zoom-in rounded-2xl object-cover"
      />
    </ImageLightbox>
  );
}

export function ImageLightbox({ src, children, alt = "" }: ImageLightboxProps) {
  const [isOpen, setIsOpen] = React.useState(false);
  const child = React.Children.only(children) as React.ReactElement<{
    onClick?: React.MouseEventHandler;
  }>;

  return (
    <>
      {React.cloneElement(child, {
        onClick: (event: React.MouseEvent) => {
          child.props.onClick?.(event);
          setIsOpen(true);
        }
      })}
      {isOpen ? <Lightbox image={src} alt={alt} onClose={() => setIsOpen(false)} /> : null}
    </>
  );
}

export interface TextOnlyTestimonialProps {
  reviewer: string;
  text: string;
  stars: number;
  source: string;
  link: string;
}

export function TextOnlyTestimonial({
  reviewer,
  text,
  stars,
  source,
  link
}: TextOnlyTestimonialProps) {
  return (
    <div className="flex h-[228px] w-[345px] flex-col items-start gap-3 bg-card p-4 shadow-[var(--shadow-soft)]">
      <p className="text-sm font-medium leading-6 text-foreground">{reviewer}</p>
      <p className="line-clamp-4 text-sm leading-6 text-muted-foreground">{text}</p>
      <div className="mt-auto flex w-full items-center justify-between gap-2 border-t border-border pt-4">
        <div className="flex items-center gap-0.5 text-[var(--Yellow)]">
          {Array.from({ length: stars }).map((_, index) => (
            <StarIcon key={index} className="size-4" />
          ))}
        </div>
        <a href={link} className="text-sm text-foreground/40 transition-colors hover:text-foreground/60">
          {source}
        </a>
      </div>
    </div>
  );
}

export function TestimonialsCarousel({ children }: { children: React.ReactNode }) {
  return (
    <div className="w-full overflow-x-auto pb-3">
      <div className="flex min-w-full snap-x gap-5 px-6">
        {React.Children.map(children, (child, index) => (
          <div key={index} className="snap-start">
            {child}
          </div>
        ))}
      </div>
    </div>
  );
}

export interface HeroMediaItem {
  image?: { url: string; alternativeText?: string };
  video?: { url: string };
  visualText?: { text: string; name: string };
}

export interface HeroGalleryProps {
  slides: HeroMediaItem[];
}

export function HeroGallery({ slides }: HeroGalleryProps) {
  const [currentIndex, setCurrentIndex] = React.useState(0);
  const safeSlides = slides.length ? slides : [{ image: { url: "", alternativeText: "" } }];

  function next() {
    setCurrentIndex((index) => (index + 1) % safeSlides.length);
  }

  function previous() {
    setCurrentIndex((index) => (index - 1 + safeSlides.length) % safeSlides.length);
  }

  function mediaProps(media: HeroMediaItem) {
    if (media.video) {
      return {
        type: "video" as const,
        src: media.video.url,
        poster: media.image?.url || "",
        altText: media.image?.alternativeText || ""
      };
    }
    return {
      type: "image" as const,
      src: media.image?.url || "",
      altText: media.image?.alternativeText || "",
      text: media.visualText ? `${media.visualText.text} - ${media.visualText.name}` : undefined
    };
  }

  return (
    <div className="space-y-4">
      <div className="grid gap-4 md:grid-cols-[2fr_1fr_1fr]">
        <div className="md:col-span-1">
          <MediaSlide {...mediaProps(safeSlides[currentIndex])} />
        </div>
        <div className="hidden md:block">
          <MediaSlide {...mediaProps(safeSlides[(currentIndex + 1) % safeSlides.length])} />
        </div>
        <div className="hidden md:block">
          <MediaSlide {...mediaProps(safeSlides[(currentIndex + 2) % safeSlides.length])} />
        </div>
      </div>
      <div className="flex items-center justify-between">
        <button type="button" onClick={previous} className="rounded-full border border-border px-4 py-2 text-sm">
          Previous
        </button>
        <div className="text-sm text-muted-foreground">
          {currentIndex + 1} / {safeSlides.length}
        </div>
        <button type="button" onClick={next} className="rounded-full border border-border px-4 py-2 text-sm">
          Next
        </button>
      </div>
    </div>
  );
}
Raw registry JSON/r/lightbox.json