Call Grid
A video call on one card: tiles that reflow, the active speaker outlined, mute and camera toggles, and leave.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/call-grid.json'use client'
import { useEffect, useState } from 'react'
import './call-grid.css'
export interface CallPerson { id: string; name: string; muted?: boolean; cam?: boolean }
export interface CallGridProps {
people: CallPerson[]
title?: string
onLeave?: () => void
fill?: 'dark' | 'light'
className?: string
}
const Mic = ({ off }: { off?: boolean }) => <svg viewBox="0 0 24 24" aria-hidden><rect x="9" y="3" width="6" height="11" rx="3" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3" />{off && <path d="M4 4l16 16" />}</svg>
const Cam = ({ off }: { off?: boolean }) => <svg viewBox="0 0 24 24" aria-hidden><rect x="3" y="7" width="12" height="10" rx="2" /><path d="M15 11l6-3v8l-6-3" />{off && <path d="M3 3l18 18" />}</svg>
const ini = (n: string) => n.split(' ').map((w) => w[0]).join('').slice(0, 2)
/** Video call participant grid with controls. */
export function CallGrid({ people, title = 'Design crit', onLeave, fill = 'dark', className }: CallGridProps) {
const [me, setMe] = useState({ muted: false, cam: true, hand: false })
const [talk, setTalk] = useState(people[0]?.id ?? 'me')
const [left, setLeft] = useState(false)
const [confirm, setConfirm] = useState(false)
const [secs, setSecs] = useState(754)
const all: (CallPerson & { hand?: boolean })[] = [{ id: 'me', name: 'You', muted: me.muted, cam: me.cam, hand: me.hand }, ...people]
useEffect(() => {
if (left) return
const speakers = all.filter((p) => !p.muted && p.id !== 'me').map((p) => p.id)
let i = 0
const id = setInterval(() => { setSecs((s) => s + 1); if (speakers.length && (i++ % 3 === 2)) setTalk(speakers[Math.floor(i / 3) % speakers.length]) }, 1000)
return () => clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [left, people])
const n = all.length, cols = n <= 1 ? 1 : n <= 4 ? 2 : 2, rows = Math.ceil(n / cols)
const mmss = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
if (left) return (
<div className={['sd-cal', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-cal__end" role="status"><b>You left the call</b><span style={{ color: 'var(--mute)', fontSize: 13 }}>{title} · {mmss}</span><button type="button" onClick={() => { setLeft(false); setConfirm(false) }}>Rejoin</button></div>
</div>
)
return (
<div className={['sd-cal', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-cal__top"><b>{title}</b><span>{n} people · {mmss}</span></div>
<div className="sd-cal__g" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }}>
{all.map((p, i) => (
<div key={p.id} className="sd-cal__t" data-talk={(talk === p.id && !p.muted) || undefined} data-cam={p.cam || undefined} style={n === 3 && i === 0 ? { gridColumn: 'span 2' } : undefined}>
<span className="sd-cal__av" aria-hidden>{ini(p.name)}</span>
<span className="sd-cal__nm">{p.muted && <Mic off />}<span>{p.name}</span></span>
{p.hand && <span className="sd-cal__hand">Hand up</span>}
</div>
))}
</div>
<p className="sr" aria-live="polite">{me.hand ? 'Your hand is raised' : ''}</p>
<div className="sd-cal__bar">
<button type="button" aria-pressed={me.muted} aria-label={me.muted ? 'Unmute' : 'Mute'} onClick={() => setMe({ ...me, muted: !me.muted })}><Mic off={me.muted} /></button>
<button type="button" aria-pressed={!me.cam} aria-label={me.cam ? 'Turn camera off' : 'Turn camera on'} onClick={() => setMe({ ...me, cam: !me.cam })}><Cam off={!me.cam} /></button>
<button type="button" aria-pressed={me.hand} aria-label="Raise hand" onClick={() => setMe({ ...me, hand: !me.hand })}><svg viewBox="0 0 24 24" aria-hidden><path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6 7-2.5 0-4-1.2-5.5-3.5L4 14a1.5 1.5 0 0 1 2.5-1.6L8 14" /></svg></button>
<button type="button" className="leave" data-confirm={confirm || undefined} onClick={() => { if (!confirm) { setConfirm(true); return } setLeft(true); onLeave?.() }} onBlur={() => setConfirm(false)}>{confirm ? 'Leave now' : 'Leave'}</button>
</div>
</div>
)
}.sd-cal { --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-cal[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-cal :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cal :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cal .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cal { width: min(100%, 350px); padding: 10px; 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; }
.sd-cal__top { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px 0; font-size: 12px; color: var(--mute); }
.sd-cal__top b { color: var(--fg); font-size: 13px; }
.sd-cal__g { display: grid; gap: 6px; height: 300px; }
.sd-cal__t { position: relative; border-radius: 12px; background: var(--soft); display: grid; place-items: center; overflow: hidden; transition: box-shadow 160ms; animation: sd-cal-in 260ms ease-out; }
@keyframes sd-cal-in { from { opacity: 0; transform: scale(0.94); } }
.sd-cal__t[data-talk] { box-shadow: inset 0 0 0 3px var(--fg); }
.sd-cal__t[data-cam] { background: color-mix(in srgb, var(--fg) 16%, var(--bg)); }
.sd-cal__av { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 15px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-cal__t[data-cam] .sd-cal__av { width: 64px; height: 64px; font-size: 20px; background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-cal__nm { position: absolute; left: 6px; bottom: 6px; display: flex; align-items: center; gap: 4px; padding: 2px 7px; border-radius: 6px; background: var(--bg); font-size: 11px; font-weight: 600; max-width: calc(100% - 12px); }
.sd-cal__nm span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-cal__nm svg, .sd-cal__bar svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; flex: none; }
.sd-cal__hand { position: absolute; top: 6px; right: 6px; padding: 2px 7px; border-radius: 6px; background: var(--strong) !important; color: var(--on) !important; font-size: 11px; font-weight: 700; }
.sd-cal__bar { display: flex; justify-content: center; gap: 8px; }
.sd-cal__bar button { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1px var(--line); }
.sd-cal__bar button svg { width: 18px; height: 18px; }
.sd-cal__bar button[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-cal__bar .leave { width: auto; padding: 0 16px; border-radius: 22px; font-size: 13px; font-weight: 700; }
.sd-cal__bar .leave[data-confirm] { background: var(--strong) !important; color: var(--on) !important; }
.sd-cal__end { display: grid; place-items: center; height: 300px; text-align: center; gap: 8px; align-content: center; }
.sd-cal__end b { font-size: 16px; }
.sd-cal__end button { height: 36px; padding: 0 16px; border-radius: 10px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
@media (prefers-reduced-motion: reduce) { .sd-cal *, .sd-cal *::before, .sd-cal *::after { animation: none !important; transition: none !important; } }Overview
Tiles reflow from one to a 2 × 3 grid as people join. Camera-off tiles show initials; muted people carry a slashed mic. The active speaker gets a thick outline that moves as the demo talks.
The control bar has mute, camera, a hand raise and Leave, which asks to confirm.
When to use
- Meeting apps, telehealth, classroom tools.
When not to use
- Large webinars: use a speaker view with a list.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/call-grid.json$
npx shadcn@latest add https://sundaeui.dev/r/call-grid.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/call-grid.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/call-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 call-grid.tsx and call-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 { CallGrid } from '@/components/sundae/call-grid'
<CallGrid />Props
| Prop | Type | Default |
|---|---|---|
people*Participants other than you. | { id: string; name: string; muted?: boolean; cam?: boolean }[] | — |
onLeaveCalled on leave. | () => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Controls are toggle buttons with aria-pressed and clear labels.
- Speaker changes and hand raises are announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cal; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Call Grid free?
- Yes. Call Grid is free for personal and commercial projects, with full source on this page.
- How do I install Call Grid?
- Run npx shadcn@latest add https://sundaeui.dev/r/call-grid.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Call Grid support keyboard and screen readers?
- Controls are toggle buttons with aria-pressed and clear labels. Speaker changes and hand raises are announced politely.
- Does Call Grid respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Next Meeting Card
Components · RefinedTimezone Slider
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined