Selectable Grid
A photo grid with selection mode: tap to pick, Shift-click a range, drag across tiles, then act on the selection.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/selectable-grid.json'use client'
import { useEffect, useRef, useState } from 'react'
import './selectable-grid.css'
export interface Tile { id: string; label: string }
export interface SelectableGridProps {
items: Tile[]
columns?: number
defaultSelecting?: boolean
onDelete?: (ids: string[]) => void
render?: (t: Tile, i: number) => React.ReactNode
fill?: 'dark' | 'light'
className?: string
}
const tones = ['#3a3a3a', '#555', '#6d6d6d', '#2b2b2b', '#808080', '#474747']
const Art = ({ i }: { i: number }) => (
<svg viewBox="0 0 60 60" preserveAspectRatio="xMidYMid slice" aria-hidden>
<rect width="60" height="60" fill={tones[i % 6]} />
{i % 3 === 0 && <circle cx={18 + (i * 7) % 24} cy="20" r="8" fill="rgb(255 255 255 / 0.55)" />}
<path d={`M0 ${44 - (i % 4) * 3} L${18 + (i % 5) * 4} ${30 + (i % 3) * 3} L60 ${46 - (i % 2) * 6} V60 H0z`} fill="rgb(0 0 0 / 0.35)" />
</svg>
)
/** Grid with tap, shift-range and drag-paint selection. */
export function SelectableGrid({ items: i0, columns = 3, defaultSelecting = false, onDelete, render, fill = 'dark', className }: SelectableGridProps) {
const [items, setItems] = useState(i0)
const [on, setOn] = useState(defaultSelecting)
const [sel, setSel] = useState<Set<string>>(new Set())
const [anchor, setAnchor] = useState(-1)
const [act, setAct] = useState(-1)
const [undo, setUndo] = useState<Tile[] | null>(null)
const paint = useRef<{ add: boolean; moved: boolean } | null>(null)
const press = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { if (!undo) return; const t = setTimeout(() => setUndo(null), 5000); return () => clearTimeout(t) }, [undo])
useEffect(() => { const up = () => { paint.current = null }; window.addEventListener('pointerup', up); return () => window.removeEventListener('pointerup', up) }, [])
const toggle = (i: number, shift: boolean) => {
const n = new Set(sel)
if (shift && anchor >= 0) { const [a, b] = [Math.min(anchor, i), Math.max(anchor, i)]; for (let k = a; k <= b; k++) n.add(items[k].id) }
else { const id = items[i].id; n.has(id) ? n.delete(id) : n.add(id); setAnchor(i) }
setSel(n); setAct(i)
}
const exit = () => { setOn(false); setSel(new Set()); setAnchor(-1) }
const del = () => { const gone = items.filter((t) => sel.has(t.id)); setUndo(items); setItems(items.filter((t) => !sel.has(t.id))); onDelete?.(gone.map((t) => t.id)); setSel(new Set()) }
const key = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') return exit()
if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); setOn(true); setSel(new Set(items.map((t) => t.id))) }
}
return (
<div className={['sd-sgr', className].filter(Boolean).join(' ')} data-fill={fill} onKeyDown={key}>
<div className="sd-sgr__top"><b>Recents</b><button type="button" className="sd-sgr__tb" onClick={() => (on ? exit() : setOn(true))}>{on ? 'Cancel' : 'Select'}</button></div>
<div className="sd-sgr__grid" role="group" aria-label="Photos" style={{ ['--c' as string]: columns }}>
{items.map((t, i) => (
<button key={t.id} type="button" className="sd-sgr__t" role={on ? 'checkbox' : undefined} aria-checked={on ? sel.has(t.id) : undefined} aria-label={t.label} data-act={on && act === i || undefined}
onPointerDown={(e) => {
if (!on) { press.current = setTimeout(() => { setOn(true); setSel(new Set([t.id])); setAnchor(i) }, 450); return }
if (e.shiftKey) return
paint.current = { add: !sel.has(t.id), moved: false }
}}
onPointerUp={() => press.current && clearTimeout(press.current)}
onPointerEnter={() => {
const p = paint.current
if (!p || !on) return
p.moved = true
setSel((s) => { const n = new Set(s); p.add ? n.add(t.id) : n.delete(t.id); return n })
}}
onPointerLeave={(e) => {
const p = paint.current
if (p && on && !p.moved && e.buttons) { p.moved = true; setSel((s) => { const n = new Set(s); p.add ? n.add(t.id) : n.delete(t.id); return n }) }
}}
onClick={(e) => { if (!on) return; if (paint.current?.moved) return; toggle(i, e.shiftKey) }}>
{render ? render(t, i) : <Art i={i} />}
{on && <span className="sd-sgr__ck" aria-hidden><svg viewBox="0 0 12 12"><path d="m2.5 6.3 2.3 2.2 4.7-5" /></svg></span>}
</button>
))}
</div>
{undo && <div className="sd-sgr__undo" role="status"><span>Deleted {undo.length - items.length} photos</span><button type="button" onClick={() => { setItems(undo); setUndo(null) }}>Undo</button></div>}
{on && (
<div className="sd-sgr__bar">
<span aria-live="polite">{sel.size ? `${sel.size} selected` : 'Select photos'}</span>
<button type="button" onClick={() => setSel(sel.size === items.length ? new Set() : new Set(items.map((t) => t.id)))}>{sel.size === items.length ? 'None' : 'All'}</button>
<button type="button" className="del" disabled={!sel.size} onClick={del}>Delete</button>
</div>
)}
</div>
)
}.sd-sgr { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-sgr[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-sgr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-sgr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-sgr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-sgr { position: relative; width: min(100%, 330px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; user-select: none; }
.sd-sgr__top { display: flex; align-items: center; justify-content: space-between; height: 48px; padding: 0 14px; }
.sd-sgr__top b { font-size: 15px; }
.sd-sgr__tb { padding: 5px 12px; border-radius: 99px; background: var(--soft) !important; font-size: 13px; font-weight: 500; }
.sd-sgr__grid { display: grid; grid-template-columns: repeat(var(--c, 3), 1fr); gap: 3px; padding: 0 3px 3px; touch-action: pan-y; outline: 0; }
.sd-sgr__t { position: relative; aspect-ratio: 1; border-radius: 4px; overflow: hidden; transition: transform 160ms cubic-bezier(0.3, 1.4, 0.4, 1); }
.sd-sgr__t svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.sd-sgr__t[aria-checked='true'] { transform: scale(0.88); border-radius: 10px; }
.sd-sgr__t[data-act] { outline: 2px solid var(--fg); outline-offset: -2px; }
.sd-sgr__ck { position: absolute; right: 6px; bottom: 6px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px #fff, 0 1px 3px rgb(0 0 0 / 0.4); transition: background-color 120ms; }
.sd-sgr__t[aria-checked='true'] .sd-sgr__ck { background: #fff; }
.sd-sgr__ck svg { position: static; width: 11px; height: 11px; fill: none; stroke: #111; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; opacity: 0; }
.sd-sgr__t[aria-checked='true'] .sd-sgr__ck svg { opacity: 1; }
.sd-sgr__bar { display: flex; align-items: center; gap: 6px; height: 50px; padding: 0 10px 0 14px; border-top: 1px solid var(--line); font-size: 13px; animation: sd-sgr-up 220ms cubic-bezier(0.3, 1.2, 0.4, 1); }
.sd-sgr__bar span { margin-right: auto; font-variant-numeric: tabular-nums; }
.sd-sgr__bar button { padding: 7px 10px; border-radius: 9px; font-size: 13px; }
.sd-sgr__bar button:hover { background: var(--soft); }
.sd-sgr__bar .del { background: var(--strong) !important; color: var(--on) !important; font-weight: 600; }
.sd-sgr__bar .del:disabled { opacity: 0.35; }
.sd-sgr__undo { position: absolute; left: 10px; right: 10px; bottom: 60px; display: flex; justify-content: space-between; padding: 9px 12px; border-radius: 12px; background: var(--strong); color: var(--on); font-size: 13px; animation: sd-sgr-up 200ms ease-out; }
.sd-sgr__undo button { font-weight: 600; text-decoration: underline; }
@keyframes sd-sgr-up { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) { .sd-sgr *, .sd-sgr *::before, .sd-sgr *::after { animation: none !important; transition: none !important; } }Overview
Tap “Select” (or long-press a tile) to enter selection mode. Click picks a tile, Shift-click picks everything between, and dragging across tiles paints a selection.
An action bar slides in with the count, Select all and a delete that leaves an undo. Escape exits selection mode.
When to use
- Photo libraries, file managers, media pickers.
When not to use
- Picking exactly one item: use a radio grid.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/selectable-grid.json$
npx shadcn@latest add https://sundaeui.dev/r/selectable-grid.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/selectable-grid.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/selectable-grid.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy selectable-grid.tsx and selectable-grid.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SelectableGrid } from '@/components/sundae/selectable-grid'
<SelectableGrid />Props
| Prop | Type | Default |
|---|---|---|
items*Tiles. | { id: string; label: string }[] | — |
columnsColumns. | number | 3 |
onDeleteBulk delete. | (ids: string[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- In selection mode tiles are checkboxes with aria-checked; the grid is a labelled group.
- Ctrl/⌘+A selects all; the count is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-sgr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Shift + click
- Select a range
- ⌘/Ctrl + A
- Select all
- Escape
- Leave selection mode
FAQ
- Is Selectable Grid free?
- Yes. Selectable Grid is free for personal and commercial projects, with full source on this page.
- How do I install Selectable Grid?
- Run npx shadcn@latest add https://sundaeui.dev/r/selectable-grid.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Selectable Grid support keyboard and screen readers?
- In selection mode tiles are checkboxes with aria-checked; the grid is a labelled group. Ctrl/⌘+A selects all; the count is announced.
- Does Selectable Grid respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Bulk Actions Bar
Components · Refined Grid / List View Switcher
Components · RefinedLightbox Gallery
Components · RefinedEmoji Picker
Components · RefinedMini Sheet
Components · RefinedToast Stack
Components · Refined