{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"expanding-gallery","type":"registry:component","title":"Expanding gallery","description":"An image opens from its exact place in the grid, becomes a spacious viewer, and finds its way back.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/expanding-gallery.tsx","type":"registry:component","content":"// Hairline UI · expanding-gallery · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { ArrowUpRight } from 'lucide-react';\nimport { GalleryViewer } from './gallery-viewer';\nimport { GalleryPicture, type GalleryImage } from './gallery-picture';\nimport { INTERACTION_FOCUS } from './ui-interaction-motion';\nimport { NumberRoll } from './number-roll';\n\nexport type { GalleryImage } from './gallery-picture';\n\nexport interface ExpandingGalleryProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {\n  images: GalleryImage[];\n  label?: string;\n  onImageChange?: (image: GalleryImage | null) => void;\n}\n\n/** An image keeps its identity from the thumbnail to the viewer and back. */\nexport function ExpandingGallery({ images, label = 'Selected work', onImageChange, className = '', ...props }: ExpandingGalleryProps) {\n  const [selected, setSelected] = React.useState<string | null>(null);\n  const frames = React.useRef(new Map<string, HTMLDivElement>());\n  const triggers = React.useRef(new Map<string, HTMLButtonElement>());\n  const openingRect = React.useRef<DOMRect | null>(null);\n  const index = images.findIndex((image) => image.id === selected);\n  const visible = index >= 0;\n\n  const select = (next: number) => {\n    const image = images[next];\n    if (!image) return;\n    if (selected === null) openingRect.current = frames.current.get(image.id)?.getBoundingClientRect() ?? null;\n    setSelected(image.id);\n    onImageChange?.(image);\n  };\n  const close = () => {\n    setSelected(null);\n    onImageChange?.(null);\n    // Focus returns to the thumbnail of the image you were on, including after browsing.\n    triggers.current.get(selected ?? '')?.focus({ preventScroll: true });\n  };\n\n  return (\n    <div className={`w-full text-foreground ${className}`} {...props}>\n      <div className=\"mb-5 flex items-baseline justify-between gap-4\">\n        <span className=\"text-[13px] font-medium\">{label}</span>\n        <span className=\"font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground\"><NumberRoll value={images.length} format={{ minimumIntegerDigits: 2 }} /> studies</span>\n      </div>\n      <div className=\"grid grid-cols-2 gap-x-3 gap-y-5 sm:gap-x-4\">\n        {images.map((image, i) => (\n          <button key={image.id} type=\"button\" aria-label={`Open ${image.title}`} aria-haspopup=\"dialog\"\n            ref={(el) => { if (el) triggers.current.set(image.id, el); else triggers.current.delete(image.id); }}\n            onClick={() => select(i)} className={`group min-w-0 cursor-zoom-in rounded-2xl text-left ${INTERACTION_FOCUS}`}>\n            <div ref={(el) => { if (el) frames.current.set(image.id, el); else frames.current.delete(image.id); }}\n              className=\"relative overflow-hidden rounded-2xl bg-muted\" style={{ aspectRatio: `${Math.max(1, image.width)} / ${Math.max(1, image.height)}`, visibility: visible && selected === image.id ? 'hidden' : undefined }}>\n              <div className=\"size-full transition-transform duration-500 ease-out group-hover:scale-[1.025] motion-reduce:transform-none\">\n                <GalleryPicture key={image.src} image={image} />\n              </div>\n              <span className=\"absolute bottom-3 right-3 flex size-7 items-center justify-center rounded-full border border-border bg-background text-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-100\">\n                <ArrowUpRight className=\"size-3.5\" aria-hidden=\"true\" />\n              </span>\n            </div>\n            <span className=\"mt-2.5 flex items-baseline justify-between gap-2 px-0.5\">\n              <span className=\"truncate text-[12px] font-medium\">{image.title}</span>\n              <span className=\"font-mono text-[10px] text-muted-foreground\">{String(i + 1).padStart(2, '0')}</span>\n            </span>\n            {image.meta && <span className=\"mt-0.5 block truncate px-0.5 text-[11px] text-muted-foreground\">{image.meta}</span>}\n          </button>\n        ))}\n      </div>\n      {visible && <GalleryViewer images={images} index={index} label={label} openingRect={openingRect.current} getOrigin={(id) => frames.current.get(id)} onSelect={select} onClose={close} />}\n    </div>\n  );\n}\n"},{"path":"registry/gallery-picture.tsx","type":"registry:component","content":"// Hairline UI · expanding-gallery · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { ImageOff } from 'lucide-react';\n\nexport interface GalleryImage {\n  id: string;\n  src: string;\n  alt: string;\n  title: string;\n  description?: string;\n  /** Intrinsic dimensions keep the thumbnail and expanded image in the same proportions. */\n  width: number;\n  height: number;\n  meta?: string;\n}\n\nexport function GalleryPicture({ image, eager = false }: { image: GalleryImage; eager?: boolean }) {\n  const [failed, setFailed] = React.useState(false);\n  return failed ? (\n    <div role=\"img\" aria-label={`${image.alt} (image unavailable)`} className=\"flex size-full flex-col items-center justify-center gap-2 bg-muted text-muted-foreground\">\n      <ImageOff className=\"size-6\" aria-hidden=\"true\" /><span className=\"text-xs\">Image unavailable</span>\n    </div>\n  ) : (\n    <img src={image.src} alt={image.alt} width={image.width} height={image.height} loading={eager ? 'eager' : 'lazy'} decoding=\"async\" draggable={false}\n      onError={() => setFailed(true)} className=\"size-full object-cover\" />\n  );\n}\n"},{"path":"registry/gallery-viewer.tsx","type":"registry:component","content":"// Hairline UI · expanding-gallery · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { ChevronLeft, ChevronRight, X } from 'lucide-react';\nimport { GalleryPicture, type GalleryImage } from './gallery-picture';\nimport { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, releaseVelocity, samplePointer, useInteractionReducedMotion, type PointerSample } from './ui-interaction-motion';\nimport { NumberRoll } from './number-roll';\nimport { TextMorph } from './text-morph';\n\ntype Props = { images: GalleryImage[]; index: number; label: string; openingRect: DOMRect | null; getOrigin: (id: string) => HTMLElement | undefined; onSelect: (index: number) => void; onClose: () => void };\nconst CONTROL = `cursor-pointer disabled:cursor-default flex size-10 shrink-0 items-center justify-center rounded-full border border-border bg-background/80 text-foreground transition-colors hover:bg-accent disabled:opacity-30 ${INTERACTION_FOCUS}`;\n\n/**\n * Holds the page still behind the viewer without touching <html>: changing the root's overflow restyles and\n * relayouts the whole page in the frame the viewer opens, which is the stutter people feel. The viewer covers the\n * screen, so stopping wheel and touch scrolling on it is enough.\n */\nfunction holdScroll(el: HTMLElement) {\n  const stop = (event: Event) => event.preventDefault();\n  el.addEventListener('wheel', stop, { passive: false });\n  el.addEventListener('touchmove', stop, { passive: false });\n  return () => {\n    el.removeEventListener('wheel', stop);\n    el.removeEventListener('touchmove', stop);\n  };\n}\nfunction rectTransform(from: DOMRect, to: DOMRect) {\n  return `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${from.width / Math.max(1, to.width)}, ${from.height / Math.max(1, to.height)})`;\n}\n\nfunction LeavingStudy({ image, direction, from }: { image: GalleryImage; direction: number; from: number }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const animation = el.animate([{ opacity: 1, transform: `translateX(${from}px)` }, { opacity: 0, transform: `translateX(${-direction * 56}px)` }], { duration: 280, easing: EASE_OUT, fill: 'forwards' });\n    return () => animation.cancel();\n  }, [direction, from]);\n  return <div ref={ref} aria-hidden=\"true\" inert className=\"pointer-events-none absolute inset-0\"><GalleryPicture image={image} eager /></div>;\n}\n\nexport function GalleryViewer({ images, index, label, openingRect, getOrigin, onSelect, onClose }: Props) {\n  const reduced = useInteractionReducedMotion();\n  const dialog = React.useRef<HTMLDivElement>(null);\n  const closeButton = React.useRef<HTMLButtonElement>(null);\n  const picture = React.useRef<HTMLDivElement>(null);\n  const content = React.useRef<HTMLDivElement>(null);\n  const surface = React.useRef<HTMLDivElement>(null);\n  const caption = React.useRef<HTMLDivElement>(null);\n  const animations = React.useRef<Animation[]>([]);\n  const closing = React.useRef(false);\n  const mounted = React.useRef(false);\n  const previousSize = React.useRef<{ width: number; height: number } | null>(null);\n  const first = React.useRef(true);\n  const direction = React.useRef(1);\n  const drag = React.useRef<{ id: number; start: number; dx: number; samples: PointerSample[] } | null>(null);\n  const id = React.useId();\n  const image = images[index];\n  const [ghost, setGhost] = React.useState<{ image: GalleryImage; direction: number; from: number } | null>(null);\n\n  React.useEffect(() => {\n    if (!ghost) return;\n    const timer = window.setTimeout(() => setGhost(null), 300);\n    return () => window.clearTimeout(timer);\n  }, [ghost]);\n\n  const cancelAnimations = () => { animations.current.forEach((animation) => animation.cancel()); animations.current = []; };\n\n  React.useLayoutEffect(() => {\n    const el = dialog.current;\n    if (!el) return;\n    mounted.current = true;\n    // A manual popover sits in the top layer like a modal dialog but doesn't make the page inert, which restyles\n    // every element on the page twice (open and close). Modality is kept by hand: aria-modal, focus trap, Escape.\n    el.showPopover();\n    closeButton.current?.focus({ preventScroll: true });\n    const unlock = holdScroll(el);\n    return () => { mounted.current = false; cancelAnimations(); if (el.matches(':popover-open')) el.hidePopover(); unlock(); first.current = true; closing.current = false; };\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const frame = picture.current;\n    if (!frame) return;\n    cancelAnimations();\n    frame.style.transform = '';\n    if (reduced) { first.current = false; return; }\n    const origin = first.current ? openingRect : getOrigin(image.id)?.getBoundingClientRect();\n    const target = frame.getBoundingClientRect();\n    const entering = first.current;\n    first.current = false;\n    if (entering) animations.current.push(frame.animate(origin ? [\n      { transform: rectTransform(origin, target), borderRadius: '16px' },\n      { transform: 'none', borderRadius: '12px' },\n    ] : [{ opacity: 0 }, { opacity: 1 }], { duration: 560, easing: EASE_OUT }));\n    else if (content.current) animations.current.push(content.current.animate([\n      { transform: `translateX(${direction.current * 40}px) scale(0.985)`, opacity: 0 },\n      { transform: 'none', opacity: 1 },\n    ], { duration: 380, easing: EASE_OUT }));\n    const size = previousSize.current;\n    previousSize.current = null;\n    if (!entering && size && (Math.abs(size.width - target.width) > 0.5 || Math.abs(size.height - target.height) > 0.5)) {\n      animations.current.push(frame.animate([\n        { width: `${size.width}px`, height: `${size.height}px` },\n        { width: `${target.width}px`, height: `${target.height}px` },\n      ], { duration: 380, easing: EASE_OUT }));\n    }\n    if (entering && surface.current) animations.current.push(surface.current.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 320, easing: 'ease-out' }));\n    if (caption.current) animations.current.push(caption.current.animate([\n      { opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' },\n    ], { duration: 320, delay: entering ? 180 : 60, fill: 'backwards', easing: EASE_OUT }));\n    return cancelAnimations;\n    // Geometry is sampled on selection; a resize simply reflows the settled viewer.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [image.id, reduced]);\n\n  const navigate = (next: number) => {\n    if (closing.current || next < 0 || next >= images.length || next === index) return;\n    direction.current = Math.sign(next - index);\n    const rect = picture.current?.getBoundingClientRect();\n    previousSize.current = rect ? { width: rect.width, height: rect.height } : null;\n    if (!reduced) setGhost({ image, direction: direction.current, from: (drag.current?.dx ?? 0) * 0.65 });\n    drag.current = null;\n    onSelect(next);\n  };\n\n  const close = async () => {\n    if (closing.current) return;\n    closing.current = true;\n    const frame = picture.current;\n    const current = frame?.getBoundingClientRect();\n    cancelAnimations();\n    const finish = () => { if (mounted.current && closing.current) { closing.current = false; if (dialog.current?.matches(':popover-open')) dialog.current.hidePopover(); onClose(); } };\n    if (reduced || !frame || !current) { finish(); return; }\n    frame.style.transform = '';\n    const base = frame.getBoundingClientRect();\n    const target = getOrigin(image.id)?.getBoundingClientRect();\n    const inView = target && target.bottom > 0 && target.top < window.innerHeight;\n    const animation = frame.animate([\n      { transform: rectTransform(current, base), opacity: 1, borderRadius: '12px' },\n      { transform: inView ? rectTransform(target, base) : 'scale(0.96)', opacity: inView ? 1 : 0, borderRadius: '16px' },\n    ], { duration: 380, easing: EASE_OUT, fill: 'forwards' });\n    animations.current.push(animation);\n    if (surface.current) animations.current.push(surface.current.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 320, fill: 'forwards' }));\n    if (caption.current) animations.current.push(caption.current.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 100, fill: 'forwards' }));\n    try { await animation.finished; finish(); } catch { finish(); }\n  };\n\n  const finishDrag = (event: React.PointerEvent, cancelled = false) => {\n    const state = drag.current;\n    if (!state || state.id !== event.pointerId) return;\n    const velocity = releaseVelocity(state.samples, event.timeStamp);\n    const next = index + (state.dx < 0 ? 1 : -1);\n    if (!cancelled && (Math.abs(state.dx) > 64 || (Math.abs(state.dx) > 12 && Math.abs(velocity) > 0.5)) && next >= 0 && next < images.length) navigate(next);\n    else if (picture.current) {\n      const frame = picture.current;\n      const from = frame.style.transform || 'none';\n      frame.style.transform = '';\n      if (!reduced) animations.current.push(frame.animate([{ transform: from }, { transform: 'none' }], { duration: 360, easing: EASE_SETTLE }));\n    }\n    drag.current = null;\n  };\n\n  return (\n    <div ref={dialog} popover=\"manual\" role=\"dialog\" aria-modal=\"true\" aria-labelledby={`${id}-title`} aria-describedby={`${id}-description`} data-lenis-prevent\n      onKeyDown={(event) => {\n        if (event.key === 'Escape') { event.preventDefault(); void close(); return; }\n        if (event.key === 'Tab') {\n          // Keep focus inside the viewer, as a modal dialog would.\n          const focusable = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex=\"-1\"])')];\n          const first = focusable[0], last = focusable[focusable.length - 1];\n          if (first && last && (event.shiftKey ? document.activeElement === first : document.activeElement === last)) {\n            event.preventDefault();\n            (event.shiftKey ? last : first).focus();\n          }\n          return;\n        }\n        if (event.key === 'PageUp' || event.key === 'PageDown') event.preventDefault();\n        if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') {\n          event.preventDefault(); navigate(event.key === 'Home' ? 0 : event.key === 'End' ? images.length - 1 : index + (event.key === 'ArrowRight' ? 1 : -1));\n        }\n      }}\n      className=\"fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground backdrop:bg-transparent\">\n      <div ref={surface} className=\"absolute inset-0 cursor-zoom-out bg-background\" onClick={() => void close()} />\n      <div className=\"pointer-events-none relative flex h-full flex-col items-center justify-center gap-5 px-4 pb-5 pt-20 sm:gap-6\">\n        <div className=\"pointer-events-auto absolute inset-x-5 top-5 flex items-center justify-between sm:inset-x-8\">\n          <span className=\"text-xs font-medium tracking-wide\">{label}</span>\n          <button ref={closeButton} type=\"button\" aria-label=\"Close gallery\" onClick={() => void close()} className={CONTROL}><X className=\"size-4\" aria-hidden=\"true\" /></button>\n        </div>\n        <div ref={picture} className=\"pointer-events-auto relative shrink-0 origin-top-left cursor-grab overflow-hidden rounded-xl bg-muted will-change-transform active:cursor-grabbing\"\n          style={{ aspectRatio: `${Math.max(1, image.width)} / ${Math.max(1, image.height)}`, width: `min(88vw, calc((100dvh - 260px) * ${Math.max(1, image.width) / Math.max(1, image.height)}), 980px)`, touchAction: 'pan-y' }}\n          onPointerDown={(event) => {\n            if (closing.current || event.button !== 0 || !event.isPrimary) return;\n            cancelAnimations();\n            event.currentTarget.setPointerCapture(event.pointerId);\n            drag.current = { id: event.pointerId, start: event.clientX, dx: 0, samples: [{ x: event.clientX, time: event.timeStamp }] };\n          }}\n          onPointerMove={(event) => {\n            const state = drag.current;\n            if (!state || state.id !== event.pointerId) return;\n            state.dx = event.clientX - state.start;\n            samplePointer(state.samples, event.clientX, event.timeStamp);\n            const edge = (index === 0 && state.dx > 0) || (index === images.length - 1 && state.dx < 0);\n            if (!reduced) event.currentTarget.style.transform = `translateX(${state.dx * (edge ? 0.18 : 0.65)}px)`;\n          }}\n          onPointerUp={(event) => finishDrag(event)} onPointerCancel={(event) => finishDrag(event, true)} onLostPointerCapture={(event) => finishDrag(event, true)}>\n          {ghost && !reduced && <LeavingStudy key={ghost.image.id} image={ghost.image} direction={ghost.direction} from={ghost.from} />}\n          <div ref={content} className=\"relative size-full\"><GalleryPicture key={image.src} image={image} eager /></div>\n        </div>\n        <div ref={caption} className=\"pointer-events-auto w-full max-w-[720px] px-2\">\n          <div className=\"flex items-start justify-between gap-5\">\n            <div className=\"min-w-0\">\n              <h2 id={`${id}-title`} className=\"text-lg font-medium tracking-[-0.025em] sm:text-2xl\"><TextMorph animateWidth={false}>{image.title}</TextMorph></h2>\n              <p id={`${id}-description`} className=\"mt-1 line-clamp-2 max-w-[500px] text-[12px] leading-relaxed text-muted-foreground sm:text-sm\"><TextMorph animateWidth={false}>{image.description ?? image.alt}</TextMorph></p>\n            </div>\n            <div className=\"flex shrink-0 items-center gap-2\">\n              <button type=\"button\" aria-label=\"Previous image\" disabled={index === 0} onClick={() => navigate(index - 1)} className={CONTROL}><ChevronLeft className=\"size-4\" aria-hidden=\"true\" /></button>\n              <button type=\"button\" aria-label=\"Next image\" disabled={index === images.length - 1} onClick={() => navigate(index + 1)} className={CONTROL}><ChevronRight className=\"size-4\" aria-hidden=\"true\" /></button>\n            </div>\n          </div>\n          <p aria-live=\"polite\" aria-atomic=\"true\" className=\"mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground\"><NumberRoll value={index + 1} format={{ minimumIntegerDigits: 2 }} /> / <NumberRoll value={images.length} format={{ minimumIntegerDigits: 2 }} />{image.meta && <span> · <TextMorph>{image.meta}</TextMorph></span>}</p>\n        </div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · expanding-gallery · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/expanding-gallery. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}