Media

Media Grid Selectable

A multi-select photo and video grid — Shift range, cmd/ctrl toggle, two-dimensional arrow keys, a selection cap, lazy thumbnails and type-icon fallbacks.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ImageIcon, ImageOff, Play, Video, VideoOff } from "lucide-react"
import { cn } from "@/lib/utils"

export interface MediaGridItem {
  /** Stable unique identity. Duplicate ids collapse under Set semantics — two tiles share one selected state. */
  id: string
  /** The original asset URL. Image items use it as the thumbnail when `thumbnailSrc` is absent. */
  src: string
  /**
   * The thumbnail the grid actually loads. **Required for video items** (the poster frame): a video
   * URL inside an `<img>` is a request that always fails, so a video without a poster renders the

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/media-grid-selectable.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MediaGridSelectable" component
(lucide-react for the check / play / type icons).

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- items: { id: string; src: string; thumbnailSrc?: string; type: "image" |
  "video"; alt: string; duration?: number; meta?: string }[].
  thumbnailSrc is what the grid actually loads; image items fall back to src.
  Video items REQUIRE thumbnailSrc (a poster frame) — feeding a video URL to
  an <img> is a guaranteed-failing round trip, so a video without a poster
  renders the type icon and issues no request at all.
- selectedIds: string[] + onSelectionChange: (ids: string[]) => void —
  fully controlled. Ids not present in items are ignored (never counted,
  never emitted); emitted arrays are always ordered by items order, not by
  click order, so the parent can splice them straight into a document.
- max?: number — selection cap. Clamp it: Math.max(0, Math.floor(max)) and
  treat non-finite as "no cap".
- minTileWidth?: number (default 140, clamped to >= 48) — feeds
  gridTemplateColumns: repeat(auto-fill, minmax(min(Npx, 100%), 1fr)).
  The inner min() is what stops a container narrower than one tile from
  overflowing.
- label?: string (default "Media library"), showToolbar?: boolean (default
  true), emptyState?: ReactNode.
- toggleOnClick?: boolean (default false) — see Behavior.

Behavior
- Selection model is the desktop file-manager one, identical to the
  useSelection hook: a plain click REPLACES the set with that one item,
  cmd (macOS) / ctrl (elsewhere) click toggles one item, Shift click takes
  the whole run between the anchor and the clicked tile. Implement it inline
  rather than importing the hook — the component must install standalone —
  and say so in a comment so anyone already using useSelection can swap it in.
- The modifier is platform-aware and read inside the handler, never during
  render: on macOS ctrl+click also fires a synthetic click, so treating ctrl
  as "additive" there would silently toggle an item on every right-click.
- The anchor does NOT move on Shift click, so the user can hold Shift and
  keep re-drawing the range from the same origin; plain and additive clicks
  do move it. A Shift click with no live anchor degrades to a plain click.
- toggleOnClick=true makes a plain click toggle instead of replace. Touch
  devices have no modifier keys, so replace-only semantics make multi-select
  impossible there; this is the one prop that closes that hole.
- Cap: when selectedIds.length >= max, every UNSELECTED tile becomes
  aria-disabled="true" (never the native disabled attribute — it would make
  the tile unfocusable, so a keyboard user could never hear why it is
  refusing) and gets aria-describedby pointing at a visible line that says
  "Limit of N reached — deselect an item to choose a different one." Selected
  tiles stay live so the user can always trade one out. A Shift range that
  would blow past the cap is filled FROM THE ANCHOR OUTWARD and stops when
  full, so what survives is "the first N from where you started", not an
  arbitrary slice.
- Toolbar: a role="status" line reading "3 of 24 selected" — a summary only,
  never the id list; announcing 24 filenames on every click is unusable. Plus
  "Select all" / "Clear" buttons that use aria-disabled + a handler guard
  rather than the native disabled attribute (pressing a button that disables
  itself blurs it and drops focus to <body>). Under a cap, "Select all"
  becomes "Select first N" and really selects only the first N.
  When showToolbar is false the status line stays, sr-only.
- Keyboard: roving tabindex (exactly one option has tabIndex 0). Arrow
  Left/Right step by one; Up/Down step by the CURRENT column count; Down from
  a ragged last row falls to the final item instead of dead-ending; Home/End
  jump to the ends; Space toggles (preventDefault, or the page scrolls);
  Shift+Space extends additively from the anchor; cmd/ctrl+A selects all.
  Move focus imperatively inside the key handler — setting the active index
  to the value it already holds is a no-op setState that React skips, so a
  "focus it in an effect" design silently stops moving focus.
- Column count is READ BACK from the laid-out grid — getComputedStyle(...)
  .gridTemplateColumns resolves to a list of used pixel tracks, so counting
  them beats re-deriving the auto-fill formula and keeps working if the
  consumer overrides the template. Measure inside a ResizeObserver callback
  (observe() fires once immediately, which doubles as the first measurement)
  and disconnect on unmount.
- Lazy thumbnails: one IntersectionObserver with rootMargin "300px" gates
  whether the <img> gets rendered at all; unobserve each tile once revealed;
  disconnect on unmount. Keep loading="lazy" and decoding="async" on the img
  as a second line of defence. Detect IntersectionObserver support through
  useSyncExternalStore with a server snapshot of `true` — the server then
  renders skeletons, matching the first client frame (no hydration
  mismatch), and a browser without IO re-renders once and reveals everything
  instead of leaving every tile stuck in a skeleton forever.
- Per-image load state is keyed by URL, not by item id, so swapping the image
  behind an id invalidates the old verdict. Besides onLoad / onError, run a
  probe in a stable ref callback: if img.complete is already true, settle it
  with naturalWidth > 0 ? loaded : error. A cached or data-URI image finishes
  before React attaches its listeners and then never fires either event —
  measured in Edge: attaching onload one task after setting src on a warm
  cached image yields complete === true and the load event never arrives.
  Without the probe those tiles pulse forever.
- Fallbacks are two different pictures, because "no poster was supplied" and
  "the poster failed" are different facts: a failed thumbnail draws the
  struck-through type icon (ImageOff / VideoOff), a video with no poster
  draws the plain type icon (Video). Using one icon for both reports a
  perfectly healthy asset as broken.
- Video tiles show a play glyph and a duration badge and NEVER autoplay —
  no <video> element is mounted; the grid is a picker, not a player. Format
  the badge by hand as m:ss / h:mm:ss (locale-independent, so no Intl call
  and no undefined-locale hazard) and put a spoken form ("1 hour 2 minutes 5
  seconds") into the accessible name.

Rendering & styling
- role="listbox" + aria-multiselectable="true" on the grid, role="option" +
  aria-selected on every direct child. Not role="grid": with
  repeat(auto-fill, …) the column count is a layout outcome, so the row
  grouping that grid/gridcell requires would have to be recomputed on every
  resize (and hidden behind display: contents to keep the layout), all to buy
  the same two-dimensional arrow keys a listbox can implement anyway.
  option is also children-presentational, so the badges never leak into the
  announcement.
- Accessible name = alt (+ "video, <spoken duration>", + meta, + "preview
  unavailable" when the thumbnail failed). The POSITION comes from
  aria-posinset / aria-setsize and the STATE from aria-selected — folding
  either into the label makes screen readers say "selected" twice.
- Selection must not rest on border colour alone: on a single-hue palette a
  1px border swap is nearly invisible. Stack three signals — a bg-primary/25
  wash over the thumbnail, the image scaled to 90% inside its frame, and a
  filled bg-primary / text-primary-foreground check badge in the corner.
  Unselected tiles reveal an empty circle on hover/focus as the affordance.
- Semantic tokens only: bg-muted tiles, border / border-primary frames,
  bg-primary/25 mask, bg-primary + text-primary-foreground badge,
  bg-background/85 + backdrop-blur for the duration badge,
  text-muted-foreground for captions and the cap note, ring-ring for focus.
  No hardcoded colours, so dark mode is free — the same wash reads as a
  darkening in light mode and a lightening in dark mode.
- Note Tailwind v4 writes scale-90 to the `scale` property, not `transform` —
  the transition list must say transition-[opacity,scale] or the tile snaps.
  Every transition carries motion-reduce:transition-none and the skeleton
  carries motion-reduce:[animation:none]; the end states (scale, mask, badge)
  still apply, so nothing becomes ambiguous when animation is off.

Customization levers
- Density: minTileWidth is the whole responsive story (140 gives 2 columns
  inside a 390px phone gutter, 6 columns at ~1040px); the grid `gap-3` and
  the aspect-square frame are the other two knobs — switch to aspect-video
  for a clip library.
- Selection semantics: toggleOnClick for touch/picker feel; delete the
  replaceWith branch entirely if you only ever want checkbox behaviour.
- Chrome: showToolbar={false} hands the count and the select-all/clear
  buttons back to your own header (the status line stays for screen
  readers); drop the meta caption for a pure contact sheet; drop the hover
  circle if you want zero chrome until something is selected.
- Cap: max plus your own copy in the note line; raise it per plan tier, or
  leave it off for unlimited selection.
- Selected treatment: the three signals are independent — keep the badge and
  the wash but drop the scale for a denser grid, or raise the wash to
  bg-primary/40 for a stronger cue on busy photography.
- Loading: rootMargin "300px" trades prefetch distance for request volume;
  the skeleton is a plain animate-pulse block you can swap for a blurhash or
  a dominant-colour block from your CDN.
- Data source: items is plain data — map it from your CMS/API and point
  thumbnailSrc at a resized derivative; the component never fetches anything
  itself and never mounts a video element.

Concepts

  • Anchor-based range — Shift extends from a remembered anchor and leaves that anchor where it is, so the user can keep re-dragging the same run; plain and additive clicks move it. That one rule is what makes "click 5, Shift-click 9, Shift-click 3" land on 3–5 instead of 3–9.
  • Cap filled from the anchor outward — when max truncates a range, the survivors are the first N counted from where the gesture started, not an arbitrary window of item order; the refusal is then explained through aria-disabled + aria-describedby rather than a silently ignored click.
  • aria-disabled, never native disabled — a natively disabled element is unfocusable and is blurred the instant it is pressed, so keyboard users can neither reach the tile that is refusing them nor stay put after clearing a selection. The handler guard does the actual blocking.
  • Measured column count — the number of columns is a layout outcome of repeat(auto-fill, …), so it is read back from the resolved grid-template-columns inside a ResizeObserver instead of re-deriving the formula; the arrow keys therefore keep matching what the user sees at any width, including a consumer-overridden template.
  • Roving tabindex with imperative focus — one option is tabbable at a time, and the key handler focuses the target node directly. Focusing "the index that is already active" is a no-op setState React skips entirely, so any design that waits for an effect to move focus quietly stops working at exactly those moments.
  • Cached-image probe — a warm-cached or data-URI thumbnail can finish before React attaches onLoad/onError, and those events then never arrive; a ref-callback check of complete / naturalWidth is the only thing that retires the skeleton on a prerendered or re-mounted grid.
  • Selection affordance beyond the border — on a single-hue palette a coloured 1px border is nearly invisible, so selection is carried by a wash, a 10% inset scale and a filled check badge together; each one is independently removable, but never all of them.

On This Page