zoom
Lightbox affordance for figures — Esc, click-outside, focus restore, and scroll lock included.
lightboxfiguredialogoverlay
Preview
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/zoom.jsonManual
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.cssNeeds 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);
}