{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "masonry",
  "type": "registry:block",
  "title": "Masonry Gallery",
  "description": "A JS masonry wall that lazy loads photos and reveals them in reading order, rising and un-blurring, then lifts each tile on a spring with a layered shadow and sliding caption. Changing columns re-flows every tile with a FLIP.",
  "docs": "Masonry Gallery was added to public/vividsites/masonry.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/masonry",
  "categories": [
    "ui",
    "media"
  ],
  "files": [
    {
      "path": "components/vividsites/masonry.html",
      "type": "registry:file",
      "target": "public/vividsites/masonry.html",
      "content": "<!--\nvs-masonry: a masonry gallery laid out in JS (each photo goes to the shortest column), with a\n  lazy, staggered reveal and a springy hover lift. Photos load only when they scroll near, then\n  fade in rising and un-blurring in reading order, 70 ms apart; until then each slot shows its\n  own colour so nothing jumps. Resizing or changing the column count re-flows the wall with a\n  FLIP: every tile springs from its old box to its new one. Hover (or focus) lifts a tile with\n  a layered shadow, eases the photo in and slides up its caption; press sinks it a little.\n  Without JS it falls back to a plain CSS grid.\n\n  Markup:  <div class=\"vs-masonry\" data-columns=\"auto\">\n             <figure class=\"vs-masonry-item\" data-color=\"#3a3029\">\n               <img data-src=\"photo.webp\" width=\"672\" height=\"900\" alt=\"...\">\n               <figcaption><b>Title</b><span>Meta</span></figcaption></figure> ...\n  width/height on the <img> give the aspect ratio before it loads (required). Use data-src to\n  lazy load, or a plain src to only animate the reveal.\n  Attributes on .vs-masonry: data-columns (\"auto\" or a number), data-margin (IntersectionObserver\n    rootMargin, default \"0px 0px -8% 0px\"); on an item: data-color (placeholder colour).\n  CSS variables: --vs-masonry-gap, -min (auto column width), -radius, -lift, -ink, -shadow (r, g, b), -font\n  JS: el.__vsMasonry.layout() after changing items; revealAll(); vsMasonry.mount(el).\n  Reduced motion: tiles appear and re-flow without animation.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-masonry\" data-columns=\"auto\">\n  <figure class=\"vs-masonry-item\" data-color=\"#3a3029\"><img data-src=\"assets/anvil.webp\" width=\"672\" height=\"900\" alt=\"An anvil glowing by a forge\">\n    <figcaption><b>Anvil, first heat</b><span>Forge · north yard</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#5f6f3e\"><img data-src=\"assets/aerial.webp\" width=\"900\" height=\"900\" alt=\"Patchwork fields seen from the air\">\n    <figcaption><b>Field pattern</b><span>From 1,200 m</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#2c3238\"><img data-src=\"assets/kelp.webp\" width=\"725\" height=\"900\" alt=\"A swirl of cream beside a ribbon of kelp\">\n    <figcaption><b>Kelp and cream</b><span>Still life, slate</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#4a3322\"><img data-src=\"assets/taproom.webp\" width=\"900\" height=\"563\" alt=\"A long wooden bar under factory windows\">\n    <figcaption><b>Taproom, 5 p.m.</b><span>Mill district</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#0d2530\"><img data-src=\"assets/fibre.webp\" width=\"672\" height=\"900\" alt=\"Points of blue light through optical fibre\">\n    <figcaption><b>Light through fibre</b><span>Lab bench</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#2e2019\"><img data-src=\"assets/hearth.webp\" width=\"900\" height=\"600\" alt=\"An open kitchen hearth with plates warming on the pass\">\n    <figcaption><b>The pass, before service</b><span>Wood-fired kitchen</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#3f4a44\"><img data-src=\"assets/lamp.webp\" width=\"672\" height=\"900\" alt=\"An angled lamp casting a long shadow on a white wall\">\n    <figcaption><b>Work lamp</b><span>Cellar studio</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#1d2a3c\"><img data-src=\"assets/rivet.webp\" width=\"900\" height=\"604\" alt=\"A copper rivet on dark indigo denim\">\n    <figcaption><b>Copper rivet</b><span>Selvedge, 14 oz</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#1f2733\"><img data-src=\"assets/crew.webp\" width=\"720\" height=\"900\" alt=\"Workers in hi-vis gathered around a fire at night\">\n    <figcaption><b>Night shift</b><span>Harbour works</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#c9ccd2\"><img data-src=\"assets/drill.webp\" width=\"900\" height=\"502\" alt=\"A tiny red tent on a white ice sheet under a pale sun\">\n    <figcaption><b>Drill site</b><span>Ice sheet, day 40</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#5b5242\"><img data-src=\"assets/rind.webp\" width=\"672\" height=\"900\" alt=\"The mottled rind of an aged cheese\">\n    <figcaption><b>Cave-aged rind</b><span>Month nine</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#1f5a4e\"><img data-src=\"assets/bay.webp\" width=\"900\" height=\"506\" alt=\"A swimmer in a green tidal pool from above\">\n    <figcaption><b>Green pool, ebb tide</b><span>Coastal shelf</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#cfc8bb\"><img data-src=\"assets/cloth.webp\" width=\"900\" height=\"675\" alt=\"Folds of undyed cloth\">\n    <figcaption><b>Undyed cloth</b><span>Weaving room</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#27466e\"><img data-src=\"assets/skins.webp\" width=\"675\" height=\"900\" alt=\"A skier climbing a snowy ridge with skis on the pack\">\n    <figcaption><b>Skins on</b><span>Couloir approach</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#4a3f4a\"><img data-src=\"assets/lighthouse.webp\" width=\"900\" height=\"502\" alt=\"A lighthouse on a dark headland under a storm sky\">\n    <figcaption><b>Last light</b><span>Headland</span></figcaption></figure>\n  <figure class=\"vs-masonry-item\" data-color=\"#3e3a36\"><img data-src=\"assets/bolt.webp\" width=\"675\" height=\"900\" alt=\"Rolls of cloth stacked in an old mill\">\n    <figcaption><b>Bolts in stock</b><span>Cloth store</span></figcaption></figure>\n</div>\n\n<style>\n.vs-masonry {\n  --vs-masonry-gap: 14px;\n  --vs-masonry-min: 220px;                       /* min column width when data-columns is \"auto\" */\n  --vs-masonry-radius: 16px;\n  --vs-masonry-lift: 8px;                        /* hover rise */\n  --vs-masonry-ink: #fbf8f2;                     /* caption text over the photo */\n  --vs-masonry-shadow: 26, 22, 18;               /* shadow colour as r, g, b */\n  --vs-masonry-font: \"Space Grotesk\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n\n  position: relative; width: 100%; font-family: var(--vs-masonry-font); -webkit-font-smoothing: antialiased;\n}\n.vs-masonry *, .vs-masonry *::before, .vs-masonry *::after { box-sizing: border-box; }\n.vs-masonry.is-ready { transition: height .6s cubic-bezier(.16,1,.3,1); }\n.vs-masonry-item { margin: 0; }\n.vs-masonry.is-ready .vs-masonry-item { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }\n.vs-masonry:not(.is-ready) { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--vs-masonry-min), 1fr)); gap: var(--vs-masonry-gap); }\n\n.vs-masonry-card {\n  position: relative; display: block; width: 100%; height: 100%; overflow: hidden; border-radius: var(--vs-masonry-radius); isolation: isolate;\n  background: var(--c, #e4dfd6); outline: none; cursor: pointer; -webkit-tap-highlight-color: transparent;\n  box-shadow: 0 0 0 .5px rgba(var(--vs-masonry-shadow), .08), 0 1px 2px rgba(var(--vs-masonry-shadow), .06), 0 0 0 0 rgba(var(--vs-masonry-shadow), 0);\n  transition: transform .55s cubic-bezier(.3,1.45,.45,1), box-shadow .45s cubic-bezier(.2,0,0,1);\n}\n.vs-masonry-card:hover, .vs-masonry-card:focus-visible {\n  transform: translateY(calc(var(--vs-masonry-lift) * -1));\n  box-shadow: 0 0 0 .5px rgba(var(--vs-masonry-shadow), .06), 0 calc(var(--vs-masonry-lift) * 1.2) calc(var(--vs-masonry-lift) * 2.6) calc(var(--vs-masonry-lift) * -.6) rgba(var(--vs-masonry-shadow), .28),\n              0 calc(var(--vs-masonry-lift) * 3.5) calc(var(--vs-masonry-lift) * 6) calc(var(--vs-masonry-lift) * -3) rgba(var(--vs-masonry-shadow), .3);\n}\n.vs-masonry-card:active { transform: translateY(calc(var(--vs-masonry-lift) * -.4)) scale(.985); transition-duration: .2s; }\n.vs-masonry-card:focus-visible { box-shadow: 0 0 0 2px #fff, 0 0 0 4px rgb(var(--vs-masonry-shadow)); }\n.vs-masonry-media { position: absolute; inset: 0; opacity: 0; }\n.vs-masonry-item.is-in .vs-masonry-media { opacity: 1; }\n.vs-masonry-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 1.1s cubic-bezier(.16,1,.3,1); -webkit-user-drag: none; }\n.vs-masonry-card:hover img, .vs-masonry-card:focus-visible img { transform: scale(1.045); }\n.vs-masonry-card figcaption {\n  position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; padding: 36px 16px 14px; display: grid; gap: 3px; pointer-events: none;\n  color: var(--vs-masonry-ink); background: linear-gradient(to top, rgba(10,8,6,.62), rgba(10,8,6,0));\n  opacity: 0; transform: translateY(10px); transition: opacity .3s cubic-bezier(.2,0,0,1), transform .5s cubic-bezier(.16,1,.3,1);\n}\n.vs-masonry-card figcaption b { font-size: 14.5px; font-weight: 500; letter-spacing: -.01em; }\n.vs-masonry-card figcaption span { font-size: 12px; opacity: .72; letter-spacing: .01em; }\n.vs-masonry-card:hover figcaption, .vs-masonry-card:focus-visible figcaption { opacity: 1; transform: none; }\n@media (hover: none) { .vs-masonry-card figcaption { opacity: 1; transform: none; padding-top: 28px; } .vs-masonry-card figcaption span { display: none; } }\n\n@media (prefers-reduced-motion: reduce) {\n  .vs-masonry.is-ready, .vs-masonry-card, .vs-masonry-media img, .vs-masonry-card figcaption { transition: none; }\n}\n</style>\n\n<script>\n(() => {\n  const RM = matchMedia('(prefers-reduced-motion: reduce)');\n\n  function mount(root) {\n    if (root.__vsMasonry) return root.__vsMasonry;\n    const items = [...root.querySelectorAll(':scope > .vs-masonry-item')];\n    // wrap each item's content: card (hover) > media (reveal) + caption\n    const S = items.map(it => {\n      const img = it.querySelector('img'), cap = it.querySelector('figcaption');\n      const card = document.createElement('div'); card.className = 'vs-masonry-card'; card.tabIndex = 0;\n      const media = document.createElement('div'); media.className = 'vs-masonry-media';\n      if (it.dataset.color) card.style.setProperty('--c', it.dataset.color);\n      media.append(img); card.append(media); if (cap) card.append(cap); it.append(card);\n      if (cap && !card.getAttribute('aria-label')) card.setAttribute('aria-label', cap.textContent.replace(/\\s+/g, ' ').trim());\n      const ar = (+img.getAttribute('width') || 4) / (+img.getAttribute('height') || 3);\n      return { it, img, card, media, ar, x: 0, y: 0, w: 0, h: 0, vx: 0, vy: 0, px: 0, py: 0, sx: 1, sy: 1, vsx: 0, vsy: 0, placed: false, seen: false, loaded: false, shown: false };\n    });\n\n    let W = 0, raf = 0, last = 0, first = true;\n    function columns() {\n      const d = root.dataset.columns; const gap = parseFloat(getComputedStyle(root).getPropertyValue('--vs-masonry-gap')) || 0;\n      const min = parseFloat(getComputedStyle(root).getPropertyValue('--vs-masonry-min')) || 220;\n      const n = d && d !== 'auto' ? Math.max(1, parseInt(d, 10)) : Math.max(1, Math.floor((W + gap) / (min + gap)));\n      return { n, gap };\n    }\n    function layout(animate = true) {\n      W = root.clientWidth; if (!W) return;\n      const { n, gap } = columns(), cw = (W - gap * (n - 1)) / n, heights = new Array(n).fill(0);\n      root.classList.add('is-ready');\n      for (const s of S) {\n        let c = 0; for (let i = 1; i < n; i++) if (heights[i] < heights[c] - 1) c = i;\n        const w = cw, h = Math.round(cw / s.ar), x = c * (cw + gap), y = heights[c];\n        heights[c] += h + gap;\n        if (s.placed && animate && !first && !RM.matches) {\n          // FLIP: keep the old box visually, then spring position and scale home\n          const ox = s.px, oy = s.py, ow = s.w, oh = s.h;\n          s.sx = ow / w * s.sx; s.sy = oh / h * s.sy; s.px = ox; s.py = oy;\n        } else { s.px = x; s.py = y; s.sx = s.sy = 1; s.vx = s.vy = s.vsx = s.vsy = 0; }\n        s.x = x; s.y = y; s.w = w; s.h = h; s.placed = true;\n        s.it.style.width = w + 'px'; s.it.style.height = h + 'px';\n      }\n      root.style.height = Math.max(0, Math.max(...heights) - gap) + 'px';\n      first = false; paint(); kick();\n    }\n    function paint() {\n      for (const s of S) s.it.style.transform = `translate3d(${s.px.toFixed(2)}px,${s.py.toFixed(2)}px,0) scale(${s.sx.toFixed(4)},${s.sy.toFixed(4)})`;\n    }\n    function frame(ts) {\n      raf = 0; const dt = Math.min(.032, (ts - (last || ts)) / 1000); last = ts; let moving = false;\n      const k = 190, c = 2 * Math.sqrt(k) * .86;\n      for (const s of S) {\n        const step = (p, v, t) => { const a = -k * (s[p] - t) - c * s[v]; s[v] += a * dt; s[p] += s[v] * dt; if (Math.abs(s[p] - t) < .01 && Math.abs(s[v]) < .05) { s[p] = t; s[v] = 0; return false; } return true; };\n        moving = step('px', 'vx', s.x) | step('py', 'vy', s.y) | step('sx', 'vsx', 1) | step('sy', 'vsy', 1) || moving;\n      }\n      paint(); if (moving) raf = requestAnimationFrame(frame); else last = 0;\n    }\n    const kick = () => { if (!raf) raf = requestAnimationFrame(frame); };\n\n    // lazy load + staggered reveal\n    let batch = [], batchT = 0;\n    function reveal() {\n      batchT = 0; const list = batch.filter(s => !s.shown).sort((a, b) => a.y - b.y || a.x - b.x); batch = [];\n      list.forEach((s, i) => {\n        s.shown = true; s.it.classList.add('is-in');\n        if (RM.matches) return;\n        s.media.animate([\n          { opacity: 0, transform: 'translateY(22px) scale(1.06)', filter: 'blur(14px)' },\n          { opacity: 1, transform: 'none', filter: 'blur(0px)' },\n        ], { duration: 1000, delay: Math.min(i, 8) * 70, easing: 'cubic-bezier(.16,1,.3,1)', fill: 'backwards' });\n      });\n    }\n    const tryShow = s => { if (s.seen && s.loaded && !s.shown) { batch.push(s); if (!batchT) batchT = setTimeout(reveal, 40); } };\n    const io = new IntersectionObserver(entries => {\n      for (const e of entries) {\n        if (!e.isIntersecting) continue; const s = S.find(q => q.it === e.target); if (!s || s.seen) continue;\n        s.seen = true; io.unobserve(e.target);\n        const src = s.img.dataset.src; if (src && !s.img.getAttribute('src')) { s.img.src = src; if (s.img.dataset.srcset) s.img.srcset = s.img.dataset.srcset; }\n        const done = () => { s.loaded = true; tryShow(s); };\n        if (s.img.complete && s.img.naturalWidth) done(); else { s.img.addEventListener('load', done, { once: true }); s.img.addEventListener('error', done, { once: true }); }\n      }\n    }, { rootMargin: root.dataset.margin || '0px 0px -8% 0px', threshold: .01 });\n    S.forEach(s => io.observe(s.it));\n\n    new ResizeObserver(() => { if (root.clientWidth !== W) layout(true); }).observe(root);\n    let sig = ''; const sign = () => { const cs = getComputedStyle(root); return [root.dataset.columns, cs.getPropertyValue('--vs-masonry-gap'), cs.getPropertyValue('--vs-masonry-min')].join('|'); };\n    new MutationObserver(() => { const q = sign(); if (q !== sig) { sig = q; layout(true); } }).observe(root, { attributes: true, attributeFilter: ['data-columns', 'style', 'class'] });\n    sig = sign();\n    layout(false);\n    const api = { layout: () => layout(true), revealAll: () => { S.forEach(s => { s.seen = s.loaded = true; batch.push(s); }); reveal(); } };\n    root.__vsMasonry = api; return api;\n  }\n  document.querySelectorAll('.vs-masonry').forEach(mount);\n  window.vsMasonry = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Media",
    "tags": [
      "masonry",
      "gallery",
      "lazy load",
      "grid",
      "hover",
      "images",
      "flip",
      "reveal"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#1a1612",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/masonry/index.html",
    "thumbnail": "https://vividsites.app/api/component/masonry/thumb.jpg",
    "video": "https://vividsites.app/api/component/masonry/thumb.mp4",
    "page": "https://vividsites.app/c/masonry"
  }
}