Carousel
UIA scroll-snap strip with previous/next buttons and dot markers that the browser generates and keeps in sync.
Components / UI
A thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set. Built on Dialog morph: the dialog is native (Escape, the backdrop where closedby is supported, the close button, the focus trap), the growth is lib/morph, the caption and the count are a polite live region, and without JavaScript each thumbnail is a link to the full file.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
972 B raw
501 B gzip.
Uses the Dialog morph script and the shared runtime (1.9 kB raw, once per site) and the shared morph helper (1.1 kB raw, once per site). With those included: 3.9 kB raw.
CSS 8.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 111 · Firefox 144 · Safari 18; needs dialog, view-transitions. Elsewhere: without view transitions, or under reduced motion, the dialog opens as a plain dialog; without closedby (Safari) a backdrop click does nothing, and Escape and the close button still close it.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Lightbox from '@moonarc/core/Lightbox';
---
<Lightbox
id="gallery"
columns={3}
items={[
{ src: '/work/one.jpg', thumb: '/work/one-thumb.jpg', alt: 'Poster, 2025', caption: 'Poster, 2025' },
{ src: '/work/two.jpg', thumb: '/work/two-thumb.jpg', alt: 'Book cover', caption: 'Book, 2024' },
{ src: '/work/three.jpg', alt: 'Sign system' },
]}
/>Owns the file, no dependency. The registry item also installs the shared runtime, the morph helper, Dialog and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/lightbox.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts, morph.ts, Dialog.astro and internal/DialogMorph.astro.
---
/**
* Lightbox — thumbnails that open full size, the image growing out of its
* own thumbnail and returning to it. Built on <Dialog morph>: the dialog is
* native, the growth is a same-document view transition through lib/morph
* (no view transitions, or reduced motion → it opens as a plain dialog).
* The full image is loaded when it opens; the thumbnails are the images on
* the page, and without JavaScript each is a link to the full file.
* ← → and the buttons move through the set; the return target follows.
*/
import type { HTMLAttributes } from 'astro/types';
import Dialog from './Dialog.astro';
interface Item {
src: string;
alt: string;
/** Thumbnail; default the full image. */
thumb?: string;
caption?: string;
}
interface Props extends HTMLAttributes<'div'> {
/** Dialog id; unique per page. */
id: string;
items: Item[];
/** Thumbnail columns. */
columns?: number;
/** Thumbnail aspect ratio (CSS). */
ratio?: string;
/** Accessible name of the gallery and the dialog. */
label?: string;
/** Accessible names of the previous, next and close buttons. */
prevLabel?: string;
nextLabel?: string;
closeLabel?: string;
/** Add the inline click handler for browsers without invoker commands. */
polyfill?: boolean;
}
const { id, items, columns = 3, ratio = '1', label = 'Gallery', prevLabel = 'Previous image', nextLabel = 'Next image', closeLabel = 'Close', polyfill = false, class: className, style, ...rest } = Astro.props;
const inline = [`--ma-lightbox-cols:${columns}`, `--ma-lightbox-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-lightbox', className]} data-ma-lightbox style={inline} {...rest}>
<div class="ma-lightbox__grid" role="group" aria-label={label}>
{
items.map((it, i) => (
<a href={it.src} class="ma-lightbox__thumb" data-i={i} data-caption={it.caption}>
<img src={it.thumb ?? it.src} alt={it.alt} loading="lazy" decoding="async" draggable="false" />
</a>
))
}
</div>
<Dialog id={id} morph polyfill={polyfill} closeLabel={closeLabel} class="ma-lightbox__dialog" aria-label={label}>
<Fragment slot="trigger" />
<figure class="ma-lightbox__figure">
<img class="ma-lightbox__img" alt="" data-lb-img />
<figcaption class="ma-lightbox__caption" aria-live="polite">
<span data-lb-caption></span>
<span class="ma-lightbox__count" data-lb-count></span>
</figcaption>
</figure>
{items.length > 1 && (
<>
<button type="button" class="ma-lightbox__nav ma-lightbox__nav--prev" data-lb="-1" aria-label={prevLabel}>
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M10 3L5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
</button>
<button type="button" class="ma-lightbox__nav ma-lightbox__nav--next" data-lb="1" aria-label={nextLabel}>
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
</button>
</>
)}
</Dialog>
</div>
<style is:global>
@layer components {
:where(.ma-lightbox__grid) {
display: grid;
grid-template-columns: repeat(var(--ma-lightbox-cols, 3), 1fr);
gap: 0.5rem;
}
:where(.ma-lightbox__thumb) {
display: block;
aspect-ratio: var(--ma-lightbox-ratio, 1);
border: 1px solid var(--ma-edge);
border-radius: 0.75rem;
overflow: clip;
background: var(--ma-edge);
}
:where(.ma-lightbox__thumb img) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: scale var(--ma-duration) var(--ma-ease);
}
:where(.ma-lightbox__thumb:hover img) {
scale: 1.04;
}
:where(.ma-lightbox__thumb:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 3px;
}
/* the dialog is the image: no panel padding, clipped corners, sized by the picture */
:where(.ma-lightbox) .ma-dialog {
width: fit-content;
max-width: min(92vw, 80rem);
max-height: 92dvh;
overflow: clip;
}
:where(.ma-lightbox) .ma-dialog__panel {
padding: 0;
}
:where(.ma-lightbox) .ma-dialog__close button {
background: var(--ma-panel);
opacity: 0.9;
}
:where(.ma-lightbox__figure) {
margin: 0;
}
:where(.ma-lightbox__img) {
display: block;
width: auto;
height: auto;
max-width: min(92vw, 80rem);
max-height: calc(92dvh - 3rem);
min-width: 16rem;
min-height: 12rem;
object-fit: contain;
background: var(--ma-edge);
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-dialog[data-loading] .ma-lightbox__img) {
opacity: 0.4;
}
:where(.ma-lightbox__caption) {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
min-height: 3rem;
padding: 0.75rem 1rem;
font-size: 0.8125rem;
opacity: 0.85;
}
:where(.ma-lightbox__count) {
font-family: ui-monospace, monospace;
font-size: 0.6875rem;
letter-spacing: 0.06em;
opacity: 0.7;
white-space: nowrap;
}
:where(.ma-lightbox__nav) {
position: absolute;
top: calc(50% - 1.5rem);
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border: 1px solid var(--ma-edge);
border-radius: 999px;
background: var(--ma-panel);
color: inherit;
cursor: pointer;
opacity: 0.85;
transition: opacity var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration) var(--ma-ease);
}
:where(.ma-lightbox__nav:hover) {
opacity: 1;
scale: 1.06;
}
:where(.ma-lightbox__nav:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
:where(.ma-lightbox__nav--prev) {
left: 0.75rem;
}
:where(.ma-lightbox__nav--next) {
right: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-lightbox__thumb img),
:where(.ma-lightbox__nav) {
transition: none;
scale: 1;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
import { morphOpen, target } from '../lib/morph';
onMount<HTMLElement>('[data-ma-lightbox]', (root, { signal }) => {
const dialog = root.querySelector('dialog') as HTMLDialogElement;
const thumbs = [...root.querySelectorAll<HTMLAnchorElement>('.ma-lightbox__thumb')];
const img = dialog.querySelector<HTMLImageElement>('[data-lb-img]')!;
const cap = dialog.querySelector<HTMLElement>('[data-lb-caption]')!;
const count = dialog.querySelector<HTMLElement>('[data-lb-count]')!;
let i = 0;
// the full image is loaded when shown; the return target of the morph follows the image on screen
const show = (n: number) => {
i = (n + thumbs.length) % thumbs.length;
const t = thumbs[i]!;
if (img.src !== t.href) {
dialog.setAttribute('data-loading', '');
img.src = t.href;
}
img.alt = t.querySelector('img')!.alt;
cap.textContent = t.dataset.caption ?? '';
count.textContent = `${i + 1} / ${thumbs.length}`;
target(dialog, t);
};
img.addEventListener('load', () => dialog.removeAttribute('data-loading'), { signal });
thumbs.forEach((t, n) =>
t.addEventListener(
'click',
(e) => {
e.preventDefault();
show(n);
morphOpen(dialog, t);
},
{ signal },
),
);
// the two buttons and the two arrow keys are one step each way
dialog.addEventListener('click', (e) => { const b = (e.target as Element).closest<HTMLElement>('[data-lb]'); b && show(i + +b.dataset.lb!); }, { signal });
dialog.addEventListener('keydown', (e) => { const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; d && show(i + d); }, { signal });
// focus lands on the thumbnail of the image that was open, not the one that was clicked
dialog.addEventListener('close', () => thumbs[i]!.focus(), { signal });
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
id | string | none | Dialog id; unique per page. |
items | { src: string; alt: string; thumb?: string; caption?: string }[] | none | The set. thumb defaults to src; alt is required: it names the thumbnail link and the full image. |
columns | number | 3 | Thumbnail columns. |
ratio | string | '1' | Thumbnail aspect ratio. |
label | string | 'Gallery' | Accessible name of the group and the dialog. |
prevLabel | string | 'Previous image' | Accessible name of the previous button. |
nextLabel | string | 'Next image' | Accessible name of the next button. |
closeLabel | string | 'Close' | Accessible name of the close button. |
polyfill | boolean | false | Inline the invoker click handler (403 B raw). The thumbnails open through the script, so the Lightbox itself needs it for nothing; kept for parity. |
No morph: the dialog opens with the Dialog's 150 ms cross-fade and closes the same way.
ClientRouterBound through the shared runtime; the view-transition-name exists only for the duration of a morph, so a navigation never sees it.
Replaces: Lightbox (PhotoSwipe / yet-another-react-lightbox) · LayoutGrid (Aceternity) · Image zoom (Medium Zoom) · layoutId image (Framer Motion). See the migration table.
A scroll-snap strip with previous/next buttons and dot markers that the browser generates and keeps in sync.
A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.
drag · click · ← →
Compares a before and an after image with a line you drag between them.
click · morphs open
A poster with a breathing play button that opens the video in a dialog: a file with native controls, or an embed.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown