Documentation menu

zoom

Lightbox affordance for figures — Esc, click-outside, focus restore, and scroll lock included.

lightboxfiguredialogoverlay

Preview

Installation

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco zoom component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/zoom.json

Manual

Copy each file from the Source section into its target path:

registry/zoom/zoom.tsx  →  components/moco/zoom.tsx
registry/zoom/zoom.css  →  components/moco/zoom.css

Needs moco items: tokens

Usage

usage.tsx
"use client";

import { Zoom } from "./zoom";

export default function ZoomDemo() {
  return (
    <figure style={{ position: "relative", maxWidth: 480, margin: 0 }}>
      <Zoom caption="A placeholder figure. Click ⤢ to enlarge, Esc or click outside to close.">
        <svg viewBox="0 0 320 180" style={{ width: "100%", display: "block", background: "var(--moco-panel)" }} role="img" aria-label="placeholder chart">
          <polyline points="10,150 80,90 150,120 220,50 310,70" fill="none" stroke="var(--moco-accent-dk)" strokeWidth="2" />
        </svg>
      </Zoom>
    </figure>
  );
}

Source

components/moco/zoom.tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import "./zoom.css";

export interface ZoomProps {
  children: React.ReactNode;
  caption?: React.ReactNode;
}

/** Lightbox affordance. Esc and click-outside close it. */
export function Zoom({ children, caption }: ZoomProps) {
  const [open, setOpen] = React.useState(false);
  const [mounted, setMounted] = React.useState(false);
  const opener = React.useRef<HTMLButtonElement>(null);

  React.useEffect(() => setMounted(true), []);

  React.useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    document.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = prev;
      opener.current?.focus();
    };
  }, [open]);

  return (
    <>
      <button
        ref={opener}
        type="button"
        className="moco-plate-zoom"
        aria-label="Enlarge this figure"
        onClick={() => setOpen(true)}
      >
        ⤢
      </button>
      {children}
      {mounted && open
        ? createPortal(
            <div
              className="moco-lightbox"
              role="dialog"
              aria-modal="true"
              aria-label="Enlarged figure"
              onClick={(e) => e.target === e.currentTarget && setOpen(false)}
            >
              <div className="moco-lightbox-inner">
                {children}
                {caption ? <figcaption className="moco-cap">{caption}</figcaption> : null}
                <button
                  type="button"
                  className="moco-btn-sm"
                  style={{ marginTop: "1rem" }}
                  onClick={() => setOpen(false)}
                >
                  Close (Esc)
                </button>
              </div>
            </div>,
            document.body,
          )
        : null}
    </>
  );
}
components/moco/zoom.css
/* Zoom sits in the top-right corner of its nearest positioned ancestor —
   give the figure/plate it lives in `position: relative`. */
.moco-plate-zoom {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  color: var(--moco-faint);
  border: 1px solid var(--moco-line);
  border-radius: 2px;
  background: var(--moco-bg);
  transition: color 150ms var(--moco-ease);
  z-index: 2;
}

.moco-plate-zoom:hover {
  color: var(--moco-accent-dk);
}

/* Lightbox */
.moco-lightbox {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: color-mix(in srgb, var(--moco-bg) 94%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  display: grid;
  place-items: center;
  padding: 2rem 1.5rem;
  overflow: auto;
}

.moco-lightbox-inner {
  width: min(1200px, 100%);
}

figcaption.moco-cap {
  display: block;
  margin-top: 0.9rem;
  font-family: var(--moco-serif);
  font-variant-numeric: normal;
  letter-spacing: 0;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--moco-muted);
  max-width: 640px;
}

.moco-btn-sm {
  border: 1px solid var(--moco-line);
  border-radius: 2px;
  padding: 0.2rem 0.6rem;
  font-size: 12px;
  color: var(--moco-muted);
  background: var(--moco-bg);
  transition:
    color 150ms var(--moco-ease),
    border-color 150ms var(--moco-ease);
}

.moco-btn-sm:hover {
  color: var(--moco-accent-dk);
  border-color: var(--moco-accent-dk);
}