Availability Grid
Paint when you are free: drag across a days-by-hours grid to mark or clear slots, with others’ overlap shown as depth.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/availability-grid.json'use client'
import { useRef, useState } from 'react'
import './availability-grid.css'
export interface AvailabilityGridProps {
days: string[]
hours?: [number, number]
others?: number[][]
groupSize?: number
onChange?: (sel: boolean[][]) => void
fill?: 'dark' | 'light'
className?: string
}
const hl = (h: number) => `${h % 12 || 12}${h < 12 ? 'a' : 'p'}`
/** Drag-to-paint availability grid with group overlap. */
export function AvailabilityGrid({ days, hours = [9, 18], others, groupSize = 3, onChange, fill = 'dark', className }: AvailabilityGridProps) {
const H = hours[1] - hours[0]
const [sel, setSel] = useState<boolean[][]>(() => days.map(() => Array(H).fill(false)))
const [focus, setFocus] = useState<[number, number]>([0, 0])
const paint = useRef<boolean | null>(null)
const viaPointer = useRef(false)
const refs = useRef<Record<string, HTMLButtonElement | null>>({})
const set = (d: number, h: number, v: boolean) => setSel((s) => s[d][h] === v ? s : s.map((col, i) => (i === d ? col.map((x, j) => (j === h ? v : x)) : col)))
const end = () => { if (paint.current !== null) { paint.current = null; onChange?.(sel) } }
const score = (d: number, h: number) => (others?.[d]?.[h] ?? 0) + (sel[d][h] ? 1 : 0)
let best: [number, number] | null = null, bs = 0
days.forEach((_, d) => { for (let h = 0; h < H; h++) { const s = score(d, h); if (s > bs) { bs = s; best = [d, h] } } })
const total = groupSize + 1
const mine = sel.flat().filter(Boolean).length
return (
<div className={['sd-avg', className].filter(Boolean).join(' ')} data-fill={fill} onPointerUp={end} onPointerLeave={end}>
<div className="sd-avg__h"><b>When are you free?</b><small>Drag to paint · {mine} hours marked</small></div>
<div className="sd-avg__g" role="grid" aria-label="Availability" style={{ gridTemplateColumns: `30px repeat(${days.length}, 1fr)` }}
onPointerMove={(e) => { if (paint.current === null) return; const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; const k = el?.closest('[data-k]')?.getAttribute('data-k'); if (k) { const [d, h] = k.split('-').map(Number); set(d, h, paint.current) } }}>
<span />
{days.map((d) => <span key={d}>{d}</span>)}
{Array.from({ length: H }, (_, h) => (
<div key={h} role="row" style={{ display: 'contents' }}>
<span className="hr">{hl(hours[0] + h)}</span>
{days.map((dn, d) => {
const o = others?.[d]?.[h] ?? 0
return (
<button key={d} ref={(el) => { refs.current[`${d}-${h}`] = el }} type="button" role="gridcell" className="sd-avg__c" data-k={`${d}-${h}`} aria-pressed={sel[d][h]} data-best={(best && best[0] === d && best[1] === h && bs > 1) || undefined}
tabIndex={focus[0] === d && focus[1] === h ? 0 : -1} aria-label={`${dn} ${hl(hours[0] + h)}, ${o} of ${groupSize} others free`}
onPointerDown={(e) => { e.preventDefault(); viaPointer.current = true; paint.current = !sel[d][h]; set(d, h, paint.current) }}
onClick={() => { if (viaPointer.current) { viaPointer.current = false; return } set(d, h, !sel[d][h]) }}
onKeyDown={(e) => { const m: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; const v = m[e.key]; if (!v) return; e.preventDefault(); const n: [number, number] = [Math.max(0, Math.min(days.length - 1, d + v[0])), Math.max(0, Math.min(H - 1, h + v[1]))]; setFocus(n); refs.current[n.join('-')]?.focus() }}>
{groupSize > 0 && <i style={{ height: `${(o / groupSize) * 100}%` }} />}
</button>
)
})}
</div>
))}
</div>
<div className="sd-avg__f" aria-live="polite">
<span>{best && bs > 1 ? <>Best: <b>{days[best[0]]} {hl(hours[0] + best[1])}</b> · {bs} of {total} free</> : 'Mark a few hours to compare'}</span>
<button type="button" disabled={!mine} onClick={() => { const n = days.map(() => Array(H).fill(false)); setSel(n); onChange?.(n) }}>Clear</button>
</div>
</div>
)
}.sd-avg { --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-avg[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-avg :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-avg :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-avg .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-avg { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; user-select: none; }
.sd-avg__h b { display: block; font-size: 14.5px; }
.sd-avg__h small { color: var(--mute); font-size: 12px; }
.sd-avg__g { display: grid; gap: 2px; touch-action: none; }
.sd-avg__g > span { font-size: 10.5px; color: var(--mute); text-align: center; font-variant-numeric: tabular-nums; }
.sd-avg__g > span.hr { text-align: right; padding-right: 4px; line-height: 22px; }
.sd-avg__c { position: relative; height: 22px; border-radius: 4px; background: var(--soft) !important; padding: 0; overflow: hidden; outline-offset: 1px; }
.sd-avg__c i { position: absolute; left: 0; right: 0; bottom: 0; background: color-mix(in srgb, var(--fg) 30%, transparent); }
.sd-avg__c[aria-pressed='true'] { background: var(--fg) !important; }
.sd-avg__c[aria-pressed='true'] i { background: color-mix(in srgb, var(--bg) 40%, transparent); }
.sd-avg__c[data-best] { box-shadow: inset 0 0 0 2px var(--fg); }
.sd-avg__c[data-best][aria-pressed='true'] { box-shadow: inset 0 0 0 2px var(--bg), 0 0 0 1.5px var(--fg); }
.sd-avg__f { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--mute); }
.sd-avg__f b { color: var(--fg); }
.sd-avg__f button { height: 28px; padding: 0 10px; border-radius: 8px; font-size: 12px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg) !important; }
@media (prefers-reduced-motion: reduce) { .sd-avg *, .sd-avg *::before, .sd-avg *::after { animation: none !important; transition: none !important; } }Overview
Press on a cell and drag; the first cell decides whether the drag paints or erases. Your slots are solid, and a small bar in each cell shows how many others are free then.
The best overlapping hour is called out below so a group can settle on a time quickly.
When to use
- Group scheduling, interview slots, tutoring hours.
When not to use
- Booking a single fixed slot: use time slots.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/availability-grid.json$
npx shadcn@latest add https://sundaeui.dev/r/availability-grid.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/availability-grid.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/availability-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 availability-grid.tsx and availability-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 { AvailabilityGrid } from '@/components/sundae/availability-grid'
<AvailabilityGrid />Props
| Prop | Type | Default |
|---|---|---|
days*Column labels. | string[] | — |
hoursStart and end hour. | [number, number] | [9, 18] |
othersCount of others free per [day][hour]. | number[][] | — |
onChangeCalled after a drag. | (sel: boolean[][]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Cells are toggle buttons in a grid labelled with day, hour and how many others are free.
- Arrow keys move; Space toggles.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-avg; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Arrow keys
- Move
- Space
- Toggle a slot
FAQ
- Is Availability Grid free?
- Yes. Availability Grid is free for personal and commercial projects, with full source on this page.
- How do I install Availability Grid?
- Run npx shadcn@latest add https://sundaeui.dev/r/availability-grid.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Availability Grid support keyboard and screen readers?
- Cells are toggle buttons in a grid labelled with day, hour and how many others are free. Arrow keys move; Space toggles.
- Does Availability Grid respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Time Slots
Forms · RefinedWeek Strip
Forms · Refined- Pro
Date Range Presets
Forms · Refined Repeat Picker
Forms · RefinedStatus Picker
Forms · RefinedMonth Picker
Forms · Refined