{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bento-grid",
  "type": "registry:block",
  "title": "Bento Grid",
  "description": "A bento grid of video tiles that play on hover or in view, with a pointer spotlight on the borders and a live now-playing tile. Click a tile and the same video grows into a full view on a FLIP spring; Esc, the backdrop or a downward flick sends it back.",
  "docs": "Bento Grid was added to public/vividsites/bento-grid.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/bento-grid",
  "categories": [
    "ui",
    "cards-layout"
  ],
  "files": [
    {
      "path": "components/vividsites/bento-grid.html",
      "type": "registry:file",
      "target": "public/vividsites/bento-grid.html",
      "content": "<!--\n  vs-bento-grid: a bento layout of video tiles that play on hover (or while in view) and open\n  into a full view on click, plus a live \"now playing\" data tile.\n\n  - Place tiles with grid-area (see grid-template-areas on .vs-bento-grid-grid).\n  - A media tile is a .vs-bento-grid-tile with a <video muted loop playsinline> and a poster\n    <img>. Videos only load metadata until first played, and every video pauses off-screen.\n  - data-autoplay on .vs-bento-grid: \"hover\" (default; mouse) or \"in-view\" (every tile plays\n    while it is on screen). Touch screens always use \"in-view\". It can be changed live.\n  - A hairline on each tile tracks the pointer (spotlight border); a bar at the foot and the\n    chip timecode show playback.\n  - Click, Enter or Space opens the tile in a modal <dialog>: the SAME video element moves into\n    it (no restart) and the view grows out of the tile on a spring (FLIP: first rect -> last\n    rect, clip-path + counter-scaled picture, so nothing stretches). The title flies from the\n    tile caption to the full view. Esc, the close button, the backdrop, or dragging the picture\n    down (with a flick) sends it back into its tile. Focus returns to the tile.\n  - .vs-bento-grid-data is an optional readout tile: it shows the title, a running timecode and\n    one progress lane per tile for whatever is playing.\n  - prefers-reduced-motion: no autoplay, the full view opens without the grow.\n\n  CSS variables (on .vs-bento-grid):\n    --vs-bento-grid-gap      gap between tiles\n    --vs-bento-grid-radius   tile radius (the full view grows from it)\n    --vs-bento-grid-row      row height\n    --vs-bento-grid-tile     tile background\n    --vs-bento-grid-ink      text colour\n    --vs-bento-grid-accent   spotlight, playback bar, live dot, focus ring\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=Inter+Tight:wght@400;500&family=DM+Mono&display=swap\" rel=\"stylesheet\">\n<style>\n.vs-bento-grid{\n  --vs-bento-grid-gap: 10px;\n  --vs-bento-grid-radius: 20px;\n  --vs-bento-grid-row: 200px;\n  --vs-bento-grid-tile: #16181b;\n  --vs-bento-grid-ink: #eef0f2;\n  --vs-bento-grid-accent: #c5f26b;\n  color: var(--vs-bento-grid-ink); font-family: \"Inter Tight\", system-ui, sans-serif; width: 100%;\n  -webkit-font-smoothing: antialiased;\n}\n.vs-bento-grid-grid{\n  display: grid; gap: var(--vs-bento-grid-gap);\n  grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: var(--vs-bento-grid-row);\n  grid-template-areas: \"a a b b\" \"a a c d\" \"e f f d\";\n}\n.vs-bento-grid-grid > *{\n  position: relative; min-width: 0; border-radius: var(--vs-bento-grid-radius); overflow: hidden; isolation: isolate;\n  background: var(--vs-bento-grid-tile);\n  box-shadow: 0 1px 2px rgba(0,0,0,.35), 0 24px 48px -28px rgba(0,0,0,.7);\n}\n/* hairline (::before) + pointer spotlight riding the border (::after) */\n.vs-bento-grid-grid > *::before{\n  content: \"\"; position: absolute; inset: 0; z-index: 5; pointer-events: none; border-radius: inherit;\n  box-shadow: inset 0 0 0 1px rgba(255,255,255,.075), inset 0 1px 0 rgba(255,255,255,.07);\n  transition: box-shadow .2s cubic-bezier(.2,0,0,1);\n}\n.vs-bento-grid-grid > *::after{\n  content: \"\"; position: absolute; inset: 0; z-index: 6; pointer-events: none; border-radius: inherit; padding: 1px;\n  background: radial-gradient(380px circle at var(--mx, -600px) var(--my, -600px), color-mix(in srgb, var(--vs-bento-grid-accent) 80%, #fff), color-mix(in srgb, var(--vs-bento-grid-accent) 45%, transparent) 30%, transparent 68%);\n  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor;\n  mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);\n  opacity: 0; transition: opacity .45s cubic-bezier(.2,0,0,1);\n}\n.vs-bento-grid.is-pointing .vs-bento-grid-grid > *::after{ opacity: 1; }\n\n/* media tile */\n.vs-bento-grid-tile{ cursor: pointer; outline: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation;\n  transition: transform .55s cubic-bezier(.16,1,.3,1); }\n.vs-bento-grid-tile:active{ transform: scale(.985); transition-duration: .12s; }\n.vs-bento-grid-tile:focus-visible::before{ box-shadow: inset 0 0 0 2px var(--vs-bento-grid-accent), inset 0 0 0 4px rgba(6,7,8,.55); }\n.vs-bento-grid-media{ position: absolute; inset: 0; transform: scale(1.001); transition: transform 1.1s cubic-bezier(.16,1,.3,1); }\n.vs-bento-grid-media > img, .vs-bento-grid-media > video, .vs-bento-grid-frame > video{\n  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }\n.vs-bento-grid-media > img{ -webkit-user-drag: none; user-select: none; }\n.vs-bento-grid-tile:hover .vs-bento-grid-media, .vs-bento-grid-tile:focus-visible .vs-bento-grid-media{ transform: scale(1.04); }\n.vs-bento-grid-media::after{ /* legibility scrim */\n  content: \"\"; position: absolute; inset: 0; pointer-events: none;\n  background: linear-gradient(to top, rgba(6,7,8,.8) 0%, rgba(6,7,8,.28) 30%, rgba(6,7,8,0) 55%),\n              linear-gradient(to bottom, rgba(6,7,8,.32), rgba(6,7,8,0) 26%);\n}\n.vs-bento-grid-tile.is-open .vs-bento-grid-media{ opacity: 0; transition: none; }\n.vs-bento-grid-tile.is-open .vs-bento-grid-cap, .vs-bento-grid-tile.is-open .vs-bento-grid-chip{ opacity: 0; transition: none; }\n.vs-bento-grid-cap{ position: absolute; left: 16px; right: 14px; bottom: 14px; z-index: 2; display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; pointer-events: none;\n  transition: transform .6s cubic-bezier(.16,1,.3,1); }\n.vs-bento-grid-tile:hover .vs-bento-grid-cap{ transform: translate3d(0,-2px,0); }\n.vs-bento-grid-cap h3{ margin: 0; font: 500 17px/1.05 \"Inter Tight\", system-ui, sans-serif; letter-spacing: -.02em; white-space: nowrap; }\n.vs-bento-grid-tile.is-big .vs-bento-grid-cap h3{ font-size: 30px; letter-spacing: -.035em; }\n.vs-bento-grid-cap p{ margin: 5px 0 0; font-size: 12.5px; line-height: 1.2; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.vs-bento-grid-open{ flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center;\n  background: rgba(255,255,255,.12); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.18);\n  -webkit-backdrop-filter: blur(10px) saturate(1.4); backdrop-filter: blur(10px) saturate(1.4);\n  transform: translate3d(0,6px,0) scale(.86); opacity: 0; transition: transform .6s cubic-bezier(.16,1,.3,1), opacity .25s cubic-bezier(.2,0,0,1); }\n.vs-bento-grid-open svg{ width: 13px; height: 13px; }\n.vs-bento-grid-tile:hover .vs-bento-grid-open, .vs-bento-grid-tile:focus-visible .vs-bento-grid-open{ transform: none; opacity: 1; }\n.vs-bento-grid-chip{ position: absolute; top: 12px; left: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 7px; pointer-events: none;\n  padding: 6px 9px 6px 8px; border-radius: 999px; background: rgba(8,9,10,.38); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.14);\n  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);\n  font: 400 10.5px/1 \"DM Mono\", ui-monospace, monospace; letter-spacing: .04em; font-variant-numeric: tabular-nums; }\n.vs-bento-grid-chip b{ font-weight: 400; }\n.vs-bento-grid-chip i{ width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.4); transition: background-color .3s, box-shadow .3s; }\n.vs-bento-grid-tile.is-playing .vs-bento-grid-chip i{ background: var(--vs-bento-grid-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--vs-bento-grid-accent) 22%, transparent); }\n.vs-bento-grid-bar{ position: absolute; left: 0; right: 0; bottom: 0; height: 2px; z-index: 3; background: var(--vs-bento-grid-accent);\n  transform-origin: 0 50%; transform: scaleX(0); opacity: 0; transition: opacity .3s; pointer-events: none; }\n.vs-bento-grid-tile.is-playing .vs-bento-grid-bar{ opacity: 1; }\n\n/* data tile: now playing */\n.vs-bento-grid-data{ padding: 16px 16px 15px; display: flex; flex-direction: column;\n  background: radial-gradient(120% 90% at 100% 0%, color-mix(in srgb, var(--vs-bento-grid-accent) 9%, transparent), transparent 60%), var(--vs-bento-grid-tile); }\n.vs-bento-grid-data-top{ display: flex; justify-content: space-between; align-items: center;\n  font: 400 10.5px/1 \"DM Mono\", ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; opacity: .55; }\n.vs-bento-grid-live{ width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.35); transition: background-color .3s, box-shadow .3s; }\n.vs-bento-grid-data.is-live .vs-bento-grid-data-top{ opacity: .8; }\n.vs-bento-grid-data.is-live .vs-bento-grid-live{ background: var(--vs-bento-grid-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--vs-bento-grid-accent) 22%, transparent); }\n.vs-bento-grid-data-title{ margin: 10px 0 0; font: 500 19px/1.1 \"Inter Tight\", system-ui, sans-serif; letter-spacing: -.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.vs-bento-grid-data-sub{ margin: 6px 0 0; font: 400 10.5px/1 \"DM Mono\", ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; opacity: .45; white-space: nowrap; }\n.vs-bento-grid-data-foot{ margin-top: auto; }\n.vs-bento-grid-tc{ font: 400 clamp(28px, 2.9vw, 40px)/1 \"Inter Tight\", system-ui, sans-serif; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }\n.vs-bento-grid-tc small{ font: 400 11px/1 \"DM Mono\", ui-monospace, monospace; letter-spacing: .02em; opacity: .45; margin-left: 5px; vertical-align: .9em; }\n.vs-bento-grid-lanes{ display: flex; gap: 4px; margin-top: 12px; }\n.vs-bento-grid-lanes i{ position: relative; flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,.1); overflow: hidden; }\n.vs-bento-grid-lanes b{ position: absolute; inset: 0; background: rgba(255,255,255,.5); transform-origin: 0 50%; transform: scaleX(0); border-radius: inherit; }\n.vs-bento-grid-lanes i.is-on b{ background: var(--vs-bento-grid-accent); }\n\n/* full view */\n.vs-bento-grid-dialog{ position: fixed; inset: 0; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;\n  background: transparent; color: var(--vs-bento-grid-ink); overflow: hidden; font-family: \"Inter Tight\", system-ui, sans-serif; outline: none;\n  --vs-bento-grid-fw: min(1100px, calc(100vw - 48px), calc((100vh - 230px) * 16 / 9)); --vs-bento-grid-fy: 44%; }\n.vs-bento-grid-dialog::backdrop{ background: transparent; }\n.vs-bento-grid-scrim{ position: absolute; inset: 0; background: rgba(5,6,7,.84); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); opacity: 0; will-change: opacity; }\n.vs-bento-grid-frame{ position: absolute; left: 50%; top: var(--vs-bento-grid-fy); width: var(--vs-bento-grid-fw); aspect-ratio: 16 / 9;\n  transform: translate(-50%, -50%); background: #000; cursor: grab; /* no overflow clip: clip-path does it, and may reach past the box */ touch-action: none; will-change: transform, clip-path; }\n.vs-bento-grid-frame:active{ cursor: grabbing; }\n.vs-bento-grid-frame > video{ will-change: transform; transform-origin: 50% 50%; }\n.vs-bento-grid-info{ position: absolute; left: 50%; top: calc(var(--vs-bento-grid-fy) + var(--vs-bento-grid-fw) * 9 / 32 + 22px); width: var(--vs-bento-grid-fw);\n  margin-left: calc(var(--vs-bento-grid-fw) / -2); display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; pointer-events: none; }\n.vs-bento-grid-info h2{ margin: 0; font: 500 clamp(26px, 3vw, 38px)/1.05 \"Inter Tight\", system-ui, sans-serif; letter-spacing: -.035em;\n  transform-origin: 0 0; will-change: transform; white-space: nowrap; }\n.vs-bento-grid-info p, .vs-bento-grid-meta{ opacity: 0; will-change: opacity, transform; }\n.vs-bento-grid-info p{ margin: 8px 0 0; max-width: 54ch; font-size: 14.5px; line-height: 1.5; color: color-mix(in srgb, var(--vs-bento-grid-ink) 66%, transparent); }\n.vs-bento-grid-meta{ display: flex; gap: 18px; font: 400 11px/1 \"DM Mono\", ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; white-space: nowrap;\n  color: color-mix(in srgb, var(--vs-bento-grid-ink) 60%, transparent); }\n.vs-bento-grid-close{ position: absolute; top: 18px; right: 18px; width: 44px; height: 44px; border-radius: 50%; border: 0; cursor: pointer; display: grid; place-items: center; padding: 0;\n  color: var(--vs-bento-grid-ink); background: rgba(255,255,255,.1); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.26), inset 0 1px 0 rgba(255,255,255,.12); opacity: 0;\n  transition: background-color .15s cubic-bezier(.2,0,0,1), transform .15s cubic-bezier(.2,0,0,1); }\n.vs-bento-grid-close:hover{ background: rgba(255,255,255,.18); }\n.vs-bento-grid-close:active{ transform: scale(.92); }\n.vs-bento-grid-close:focus-visible{ outline: 2px solid var(--vs-bento-grid-accent); outline-offset: 3px; }\n.vs-bento-grid-close svg{ width: 16px; height: 16px; }\n\n@media (max-width: 760px){\n  .vs-bento-grid{ --vs-bento-grid-row: 128px; --vs-bento-grid-gap: 8px; --vs-bento-grid-radius: 16px; }\n  .vs-bento-grid-grid{ grid-template-columns: 1fr 1fr; grid-template-areas: \"a a\" \"a a\" \"b b\" \"c d\" \"e d\" \"f f\"; }\n  .vs-bento-grid-tile.is-big .vs-bento-grid-cap h3{ font-size: 24px; }\n  .vs-bento-grid-cap{ left: 12px; right: 10px; bottom: 11px; }\n  .vs-bento-grid-cap h3{ font-size: 15px; }\n  .vs-bento-grid-cap p{ display: none; }\n  .vs-bento-grid-tile.is-big .vs-bento-grid-cap p{ display: block; }\n  .vs-bento-grid-chip{ top: 9px; left: 9px; }\n  .vs-bento-grid-data{ padding: 12px; }\n  .vs-bento-grid-data-title{ font-size: 15px; margin-top: 8px; }\n  .vs-bento-grid-data-sub{ display: none; }\n  .vs-bento-grid-tc{ font-size: 26px; }\n  .vs-bento-grid-lanes{ margin-top: 9px; }\n  .vs-bento-grid-dialog{ --vs-bento-grid-fw: calc(100vw - 24px); --vs-bento-grid-fy: 38%; }\n  .vs-bento-grid-info{ flex-direction: column; align-items: flex-start; gap: 14px; }\n  .vs-bento-grid-meta{ gap: 14px; }\n}\n@media (prefers-reduced-motion: reduce){\n  .vs-bento-grid-media, .vs-bento-grid-open, .vs-bento-grid-cap, .vs-bento-grid-tile{ transition: none; }\n}\n</style>\n\n<section class=\"vs-bento-grid\" data-autoplay=\"hover\" aria-label=\"Motion reel\">\n  <div class=\"vs-bento-grid-grid\">\n    <article class=\"vs-bento-grid-tile is-big\" style=\"grid-area:a\" tabindex=\"0\" role=\"button\" aria-haspopup=\"dialog\" aria-label=\"Relief, chrome study. Open full view\"\n      data-title=\"Relief\" data-text=\"A liquid-chrome bust turning under a single softbox. Forty-one passes on the same curve until the highlight ran clean.\" data-meta=\"Chrome study|2026|0:05 loop\">\n      <div class=\"vs-bento-grid-media\"><img src=\"assets/relief.webp\" alt=\"\" draggable=\"false\"><video src=\"assets/relief.mp4\" poster=\"assets/relief.webp\" muted loop playsinline preload=\"metadata\" aria-hidden=\"true\"></video></div>\n      <span class=\"vs-bento-grid-chip\"><i></i><b>0:05</b></span>\n      <div class=\"vs-bento-grid-cap\"><div><h3>Relief</h3><p>Chrome study, 41 passes</p></div><span class=\"vs-bento-grid-open\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9.5 2.5h4v4\"/><path d=\"M6.5 13.5h-4v-4\"/><path d=\"M13.5 2.5L9 7\"/><path d=\"M2.5 13.5L7 9\"/></svg></span></div>\n      <span class=\"vs-bento-grid-bar\"></span>\n    </article>\n    <article class=\"vs-bento-grid-tile\" style=\"grid-area:b\" tabindex=\"0\" role=\"button\" aria-haspopup=\"dialog\" aria-label=\"Long Night, planetarium titles. Open full view\"\n      data-title=\"Long Night\" data-text=\"A spiral galaxy seen from above, four million particles lit only by their own core.\" data-meta=\"Title sequence|2025|0:05 loop\">\n      <div class=\"vs-bento-grid-media\"><img src=\"assets/long-night.webp\" alt=\"\" draggable=\"false\"><video src=\"assets/long-night.mp4\" poster=\"assets/long-night.webp\" muted loop playsinline preload=\"metadata\" aria-hidden=\"true\"></video></div>\n      <span class=\"vs-bento-grid-chip\"><i></i><b>0:05</b></span>\n      <div class=\"vs-bento-grid-cap\"><div><h3>Long Night</h3><p>Planetarium titles</p></div><span class=\"vs-bento-grid-open\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9.5 2.5h4v4\"/><path d=\"M6.5 13.5h-4v-4\"/><path d=\"M13.5 2.5L9 7\"/><path d=\"M2.5 13.5L7 9\"/></svg></span></div>\n      <span class=\"vs-bento-grid-bar\"></span>\n    </article>\n    <div class=\"vs-bento-grid-data\" style=\"grid-area:c\" aria-hidden=\"true\">\n      <div class=\"vs-bento-grid-data-top\"><span>Now playing</span><i class=\"vs-bento-grid-live\"></i></div>\n      <p class=\"vs-bento-grid-data-title\" data-slot=\"now\">&mdash;</p>\n      <p class=\"vs-bento-grid-data-sub\" data-slot=\"idx\">Hover a tile</p>\n      <div class=\"vs-bento-grid-data-foot\">\n        <div class=\"vs-bento-grid-tc\" data-slot=\"tc\">00:00<small>:00</small></div>\n        <div class=\"vs-bento-grid-lanes\" data-slot=\"lanes\"></div>\n      </div>\n    </div>\n    <article class=\"vs-bento-grid-tile is-tall\" style=\"grid-area:d\" tabindex=\"0\" role=\"button\" aria-haspopup=\"dialog\" aria-label=\"Lantern Sea, key art in motion. Open full view\"\n      data-title=\"Lantern Sea\" data-text=\"A lone boat under a break in the storm. The light shaft was sculpted, not simulated.\" data-meta=\"Key art|2025|0:05 loop\">\n      <div class=\"vs-bento-grid-media\"><img src=\"assets/lantern-sea.webp\" alt=\"\" draggable=\"false\"><video src=\"assets/lantern-sea.mp4\" poster=\"assets/lantern-sea.webp\" muted loop playsinline preload=\"metadata\" aria-hidden=\"true\"></video></div>\n      <span class=\"vs-bento-grid-chip\"><i></i><b>0:05</b></span>\n      <div class=\"vs-bento-grid-cap\"><div><h3>Lantern Sea</h3><p>Key art in motion</p></div><span class=\"vs-bento-grid-open\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9.5 2.5h4v4\"/><path d=\"M6.5 13.5h-4v-4\"/><path d=\"M13.5 2.5L9 7\"/><path d=\"M2.5 13.5L7 9\"/></svg></span></div>\n      <span class=\"vs-bento-grid-bar\"></span>\n    </article>\n    <article class=\"vs-bento-grid-tile\" style=\"grid-area:e\" tabindex=\"0\" role=\"button\" aria-haspopup=\"dialog\" aria-label=\"Borosilicate, product film. Open full view\"\n      data-title=\"Borosilicate\" data-text=\"One glass tube, one move. Refraction solved in camera so the grade had nothing to hide.\" data-meta=\"Product film|2026|0:05 loop\">\n      <div class=\"vs-bento-grid-media\"><img src=\"assets/borosilicate.webp\" alt=\"\" draggable=\"false\"><video src=\"assets/borosilicate.mp4\" poster=\"assets/borosilicate.webp\" muted loop playsinline preload=\"metadata\" aria-hidden=\"true\"></video></div>\n      <span class=\"vs-bento-grid-chip\"><i></i><b>0:05</b></span>\n      <div class=\"vs-bento-grid-cap\"><div><h3>Borosilicate</h3><p>Product film</p></div><span class=\"vs-bento-grid-open\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9.5 2.5h4v4\"/><path d=\"M6.5 13.5h-4v-4\"/><path d=\"M13.5 2.5L9 7\"/><path d=\"M2.5 13.5L7 9\"/></svg></span></div>\n      <span class=\"vs-bento-grid-bar\"></span>\n    </article>\n    <article class=\"vs-bento-grid-tile\" style=\"grid-area:f\" tabindex=\"0\" role=\"button\" aria-haspopup=\"dialog\" aria-label=\"Ember Dunes, music video. Open full view\"\n      data-title=\"Ember Dunes\" data-text=\"A river of lava through violet dunes at blue hour, for a band that asked for heat without fire.\" data-meta=\"Music video|2026|0:05 loop\">\n      <div class=\"vs-bento-grid-media\"><img src=\"assets/ember-dunes.webp\" alt=\"\" draggable=\"false\"><video src=\"assets/ember-dunes.mp4\" poster=\"assets/ember-dunes.webp\" muted loop playsinline preload=\"metadata\" aria-hidden=\"true\"></video></div>\n      <span class=\"vs-bento-grid-chip\"><i></i><b>0:05</b></span>\n      <div class=\"vs-bento-grid-cap\"><div><h3>Ember Dunes</h3><p>Music video, 3D world</p></div><span class=\"vs-bento-grid-open\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9.5 2.5h4v4\"/><path d=\"M6.5 13.5h-4v-4\"/><path d=\"M13.5 2.5L9 7\"/><path d=\"M2.5 13.5L7 9\"/></svg></span></div>\n      <span class=\"vs-bento-grid-bar\"></span>\n    </article>\n  </div>\n  <dialog class=\"vs-bento-grid-dialog\" aria-label=\"Full view\">\n    <div class=\"vs-bento-grid-scrim\"></div>\n    <div class=\"vs-bento-grid-frame\"></div>\n    <div class=\"vs-bento-grid-info\"><div><h2 data-slot=\"title\"></h2><p data-slot=\"text\"></p></div><div class=\"vs-bento-grid-meta\" data-slot=\"meta\"></div></div>\n    <button class=\"vs-bento-grid-close\" type=\"button\" aria-label=\"Close full view\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M3.5 3.5l9 9M12.5 3.5l-9 9\"/></svg></button>\n  </dialog>\n</section>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const noHover = matchMedia('(hover: none)');\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n  const pad = n => String(n).padStart(2, '0');\n  function init(root) {\n    if (root.dataset.vsReady) return; root.dataset.vsReady = '1';\n    const grid = root.querySelector('.vs-bento-grid-grid');\n    const tiles = [...root.querySelectorAll('.vs-bento-grid-tile')].map(el => ({\n      el, video: el.querySelector('video'), bar: el.querySelector('.vs-bento-grid-bar'), media: el.querySelector('.vs-bento-grid-media'),\n      chip: el.querySelector('.vs-bento-grid-chip b'), h3: el.querySelector('h3'), hover: false, inView: false, sec: -1, lane: null,\n    }));\n    const inViewMode = () => root.dataset.autoplay === 'in-view' || noHover.matches;\n\n    // ---- \"now playing\" data tile ----\n    const data = root.querySelector('.vs-bento-grid-data');\n    const nowEl = data && data.querySelector('[data-slot=\"now\"]'), idxEl = data && data.querySelector('[data-slot=\"idx\"]'), tcEl = data && data.querySelector('[data-slot=\"tc\"]');\n    const lanesEl = data && data.querySelector('[data-slot=\"lanes\"]');\n    if (lanesEl) tiles.forEach(t => { const i = document.createElement('i'), b = document.createElement('b'); i.append(b); lanesEl.append(i); t.lane = { i, b }; });\n    let focus = null, lastTc = '';\n    const setFocus = t => {\n      if (!data || focus === t) return; focus = t;\n      nowEl.textContent = t ? t.el.dataset.title || '' : '—';\n      if (idxEl && t) idxEl.textContent = `${pad(tiles.indexOf(t) + 1)} / ${pad(tiles.length)} · ${(t.el.dataset.meta || '').split('|')[0]}`;\n      tiles.forEach(x => x.lane && x.lane.i.classList.toggle('is-on', x === t));\n    };\n    const liveState = () => {\n      if (!data) return;\n      const playing = tiles.filter(t => !t.video.paused);\n      if (!focus || focus.video.paused) setFocus(playing.find(t => t.hover) || playing[0] || focus);\n      data.classList.toggle('is-live', playing.length > 0);\n    };\n\n    // ---- playback loop: bars, chip timecodes, readout. Runs only while something plays ----\n    let barRaf = 0;\n    const bars = () => {\n      let any = false;\n      for (const t of tiles) {\n        const v = t.video, d = v.duration;\n        if (!d || v.paused) continue;\n        any = true;\n        const k = (v.currentTime / d).toFixed(4);\n        t.bar.style.transform = `scaleX(${k})`;\n        if (t.lane) t.lane.b.style.transform = `scaleX(${k})`;\n        const s = Math.floor(v.currentTime);\n        if (s !== t.sec) { t.sec = s; if (t.chip) t.chip.textContent = `0:${pad(s)}`; }\n      }\n      if (focus && tcEl) {\n        const c = focus.video.currentTime, tc = `${pad(Math.floor(c / 60))}:${pad(Math.floor(c % 60))}<small>:${pad(Math.floor((c % 1) * 24))}</small>`;\n        if (tc !== lastTc) { lastTc = tc; tcEl.innerHTML = tc; }\n      }\n      barRaf = any ? requestAnimationFrame(bars) : 0;\n    };\n    const sync = t => {\n      if (t === openTile) return;                    // the full view owns its video\n      const want = !reduce.matches && t.inView && (t.hover || inViewMode());\n      if (want && t.video.paused) {\n        t.video.preload = 'auto';\n        const p = t.video.play(); if (p) p.catch(() => {});\n      } else if (!want && !t.video.paused) t.video.pause();\n    };\n    tiles.forEach(t => {\n      t.video.addEventListener('playing', () => { t.el.classList.add('is-playing'); liveState(); if (!barRaf) barRaf = requestAnimationFrame(bars); });\n      t.video.addEventListener('pause', () => { t.el.classList.remove('is-playing'); liveState(); });\n      t.video.addEventListener('loadedmetadata', () => { if (t.chip && t.video.paused) t.chip.textContent = `0:${pad(Math.round(t.video.duration))}`; });\n      t.el.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { t.hover = true; sync(t); if (!t.video.paused || !reduce.matches) setFocus(t); } });\n      t.el.addEventListener('pointerleave', () => { t.hover = false; sync(t); });\n      t.el.addEventListener('focus', () => { t.hover = true; sync(t); setFocus(t); });\n      t.el.addEventListener('blur', () => { t.hover = false; sync(t); });\n      t.el.addEventListener('click', () => open(t));\n      t.el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(t); } });\n    });\n    const io = new IntersectionObserver(es => es.forEach(e => {\n      const t = tiles.find(x => x.el === e.target); if (!t) return; t.inView = e.intersectionRatio >= 0.5; sync(t);\n    }), { threshold: [0, 0.5, 1] });\n    tiles.forEach(t => io.observe(t.el));\n    // data-autoplay can change live\n    new MutationObserver(() => tiles.forEach(sync)).observe(root, { attributes: true, attributeFilter: ['data-autoplay'] });\n\n    // ---- spotlight border: one pointer, every tile gets its own local coords ----\n    const cells = [...grid.children];\n    let rects = null, px = 0, py = 0, spotRaf = 0;\n    const spot = () => {\n      spotRaf = 0; if (!rects) rects = cells.map(c => c.getBoundingClientRect());\n      cells.forEach((c, i) => { c.style.setProperty('--mx', (px - rects[i].left).toFixed(1) + 'px'); c.style.setProperty('--my', (py - rects[i].top).toFixed(1) + 'px'); });\n    };\n    grid.addEventListener('pointermove', e => { if (e.pointerType !== 'mouse') return; px = e.clientX; py = e.clientY; root.classList.add('is-pointing'); if (!spotRaf) spotRaf = requestAnimationFrame(spot); });\n    grid.addEventListener('pointerleave', () => root.classList.remove('is-pointing'));\n    const dropRects = () => { rects = null; };\n    addEventListener('scroll', dropRects, { passive: true }); addEventListener('resize', dropRects);\n    new ResizeObserver(dropRects).observe(grid);\n\n    // ---- full view: FLIP on a spring (first = tile rect, last = frame rect) ----\n    const dlg = root.querySelector('.vs-bento-grid-dialog');\n    if (!dlg) return;\n    const frame = dlg.querySelector('.vs-bento-grid-frame'), scrim = dlg.querySelector('.vs-bento-grid-scrim');\n    const closeBtn = dlg.querySelector('.vs-bento-grid-close');\n    const fTitle = dlg.querySelector('[data-slot=\"title\"]'), fText = dlg.querySelector('[data-slot=\"text\"]'), fMeta = dlg.querySelector('[data-slot=\"meta\"]');\n    let openTile = null, raf = 0, last = 0, r0 = null, r1 = null, a0 = null, a1 = null, R0 = 20, R1 = 22, onDone = null;\n    const sp = { x: 0, v: 0, t: 0 };                   // 0 = in the tile, 1 = full view (under-damped: a small overshoot)\n    const dr = { x: 0, v: 0, held: false, y0: 0, ly: 0, lt: 0 };   // drag-to-dismiss offset (px)\n    const K = 230, C = 25, KD = 300, CD = 32;\n    const paint = () => {\n      const p = sp.x, q = 1 - p, cp = clamp(p, 0, 1.2);\n      const ins = [(r0.top - r1.top) * q, (r1.right - r0.right) * q, (r1.bottom - r0.bottom) * q, (r0.left - r1.left) * q];\n      frame.style.clipPath = `inset(${ins.map(v => v.toFixed(2) + 'px').join(' ')} round ${(R0 + (R1 - R0) * clamp(p, 0, 1)).toFixed(2)}px)`;\n      // the picture is counter-scaled so at p = 0 it is exactly the tile's own cover crop\n      const s0 = Math.max(r0.width / r1.width, r0.height / r1.height);\n      const dx = (r0.left + r0.width / 2) - (r1.left + r1.width / 2), dy = (r0.top + r0.height / 2) - (r1.top + r1.height / 2);\n      if (openTile) openTile.video.style.transform = `translate3d(${(dx * q).toFixed(2)}px,${(dy * q).toFixed(2)}px,0) scale(${(s0 + (1 - s0) * p).toFixed(4)})`;\n      // drag: the whole view follows the finger and shrinks a touch\n      const d = dr.x, pull = clamp(Math.abs(d) / 420, 0, 1), k = 1 - 0.14 * pull;\n      // shrink about the centre of the visible (clipped) picture, not the frame box\n      frame.style.transformOrigin = `${(ins[3] + (r1.width - ins[3] - ins[1]) / 2).toFixed(1)}px ${(ins[0] + (r1.height - ins[0] - ins[2]) / 2).toFixed(1)}px`;\n      frame.style.transform = `translate(-50%,-50%) translate3d(0,${d.toFixed(2)}px,0) scale(${k.toFixed(4)})`;\n      scrim.style.opacity = (clamp(p, 0, 1) * (1 - 0.7 * pull)).toFixed(3);\n      // title flies from the tile caption to its place under the view\n      const ts = (a0.height / a1.height), sc = ts + (1 - ts) * cp;\n      fTitle.style.transform = `translate3d(${((a0.left - a1.left) * q).toFixed(2)}px,${((a0.top - a1.top) * q + d * 0.9).toFixed(2)}px,0) scale(${sc.toFixed(4)})`;\n      const o = clamp((p - 0.6) / 0.4, 0, 1) * (1 - clamp(Math.abs(d) / 140, 0, 1));\n      const oy = ((1 - o) * 10).toFixed(2);\n      fText.style.opacity = o.toFixed(3); fMeta.style.opacity = o.toFixed(3);\n      fText.style.transform = fMeta.style.transform = `translate3d(0,${oy}px,0)`;\n      closeBtn.style.opacity = o.toFixed(3);\n    };\n    const tick = now => {\n      const dt = Math.min(0.05, (now - last) / 1000); last = now;\n      const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) {\n        sp.v += (-K * (sp.x - sp.t) - C * sp.v) * h; sp.x += sp.v * h;\n        if (sp.t === 0 && sp.x < 0) { sp.x = 0; sp.v = 0; }   // lands in its slot: no undershoot on the way home\n        if (!dr.held) { dr.v += (-KD * dr.x - CD * dr.v) * h; dr.x += dr.v * h; }\n      }\n      const still = Math.abs(sp.x - sp.t) < 5e-4 && Math.abs(sp.v) < 5e-3 && (dr.held || (Math.abs(dr.x) < 0.1 && Math.abs(dr.v) < 1));\n      if (still) {\n        sp.x = sp.t; sp.v = 0; if (!dr.held) { dr.x = 0; dr.v = 0; }\n        paint(); raf = 0; const f = onDone; onDone = null; f && f(); return;\n      }\n      paint(); raf = requestAnimationFrame(tick);\n    };\n    const kick = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n    const run = (to, done) => {\n      sp.t = to; onDone = done;\n      if (reduce.matches) { sp.x = to; sp.v = 0; dr.x = dr.v = 0; paint(); onDone = null; done && done(); return; }\n      kick();\n    };\n    const measureTitle = t => { a0 = t.h3.getBoundingClientRect(); fTitle.style.transform = 'none'; a1 = fTitle.getBoundingClientRect(); };\n    function open(t) {\n      if (openTile) return;\n      openTile = t;\n      fTitle.textContent = t.el.dataset.title || ''; fText.textContent = t.el.dataset.text || '';\n      fMeta.innerHTML = ''; (t.el.dataset.meta || '').split('|').filter(Boolean).forEach(m => { const s = document.createElement('span'); s.textContent = m.trim(); fMeta.append(s); });\n      r0 = t.el.getBoundingClientRect(); R0 = parseFloat(getComputedStyle(t.el).borderTopLeftRadius) || 0;\n      dlg.showModal(); document.documentElement.style.overflow = 'hidden';\n      R1 = innerWidth < 760 ? 16 : 22;\n      frame.style.transform = ''; r1 = frame.getBoundingClientRect();\n      measureTitle(t);\n      const time = t.video.currentTime;\n      frame.append(t.video);                         // same element: it keeps its frame and buffer\n      if (Math.abs(t.video.currentTime - time) > 0.05) t.video.currentTime = time;\n      if (!reduce.matches) { const p = t.video.play(); if (p) p.catch(() => {}); }\n      t.el.classList.add('is-open'); setFocus(t);\n      sp.x = 0; sp.v = 0; dr.x = dr.v = 0; paint();\n      closeBtn.focus({ preventScroll: true });\n      run(1);\n    }\n    function close(v0) {\n      if (!openTile || sp.t === 0) return;\n      const t = openTile;\n      r0 = t.el.getBoundingClientRect(); a0 = t.h3.getBoundingClientRect();\n      if (typeof v0 === 'number') sp.v = v0;\n      run(0, () => {\n        t.video.style.transform = ''; t.media.append(t.video);\n        t.el.classList.remove('is-open');\n        dlg.close(); document.documentElement.style.overflow = '';\n        frame.style.clipPath = ''; frame.style.transform = '';\n        openTile = null; t.hover = t.el.matches(':hover'); t.el.focus({ preventScroll: true }); sync(t);\n      });\n    }\n    dlg.addEventListener('cancel', e => { e.preventDefault(); close(); });\n    closeBtn.addEventListener('click', () => close());\n    scrim.addEventListener('click', () => close());\n    // drag down (or flick) to dismiss; upward drags are rubber-banded\n    frame.addEventListener('pointerdown', e => {\n      if (!openTile || sp.t !== 1 || e.button > 0) return;\n      frame.setPointerCapture(e.pointerId);\n      dr.held = true; dr.y0 = e.clientY - dr.x; dr.ly = e.clientY; dr.lt = performance.now(); dr.v = 0;\n    });\n    frame.addEventListener('pointermove', e => {\n      if (!dr.held) return;\n      const raw = e.clientY - dr.y0, now = performance.now(), dt = Math.max(1, now - dr.lt);\n      dr.v = dr.v * 0.6 + ((e.clientY - dr.ly) / dt * 1000) * 0.4; dr.ly = e.clientY; dr.lt = now;\n      dr.x = raw > 0 ? raw : -Math.sqrt(-raw) * 5;\n      paint();\n    });\n    const release = () => {\n      if (!dr.held) return; dr.held = false;\n      if (dr.x > 110 || (dr.v > 700 && dr.x > 12)) close(-Math.min(6, dr.v / 400)); else kick();\n    };\n    frame.addEventListener('pointerup', release); frame.addEventListener('pointercancel', release);\n    addEventListener('resize', () => {\n      if (!openTile) return;\n      frame.style.transform = ''; r1 = frame.getBoundingClientRect(); r0 = openTile.el.getBoundingClientRect(); measureTitle(openTile); paint();\n    });\n  }\n  const boot = () => document.querySelectorAll('.vs-bento-grid').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Cards & layout",
    "tags": [
      "bento",
      "grid",
      "video",
      "hover play",
      "lightbox",
      "flip",
      "spring",
      "spotlight",
      "dialog"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&family=DM+Mono&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#c5f26b",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/bento-grid/index.html",
    "thumbnail": "https://vividsites.app/api/component/bento-grid/thumb.jpg",
    "video": "https://vividsites.app/api/component/bento-grid/thumb.mp4",
    "page": "https://vividsites.app/c/bento-grid"
  }
}