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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/media-grid-selectable.jsonPrompt
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
maxtruncates 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 througharia-disabled+aria-describedbyrather than a silently ignored click. aria-disabled, never nativedisabled— 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 resolvedgrid-template-columnsinside aResizeObserverinstead 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
setStateReact 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 ofcomplete/naturalWidthis 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.
Audio Waveform
A canvas waveform with a click/drag/keyboard scrubber — feed it precomputed peaks or let it decode a src, and it reports seeks while your own audio element plays.
Audio Visualizer
A canvas spectrum, waveform, ring or level meter driven by a real AnalyserNode — you own the mic, it only reads it.