Shift Roster
Plan a week of shifts: tap cells to cycle Off, Early, Late and Night, with coverage gaps and hours per person.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/shift-roster.json'use client'
import { useState } from 'react'
import './shift-roster.css'
const DAYS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
const DN = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const CYCLE = ['', 'E', 'L', 'N']
const NAME: Record<string, string> = { '': 'off', E: 'early', L: 'late', N: 'night' }
export interface ShiftRosterProps {
people?: string[]
limit?: number
needNight?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Weekly shift roster grid with coverage checks. */
export function ShiftRoster({ people = ['Ana', 'Ben', 'Chidi', 'Dara', 'Eli'], limit = 40, needNight = false, fill = 'dark', className }: ShiftRosterProps) {
const [g, setG] = useState<string[][]>(() => [['E', 'E', 'E', 'E', 'E', '', ''], ['L', 'L', '', 'L', 'L', 'L', ''], ['', 'N', 'L', '', '', 'E', 'E'], ['N', '', 'N', 'N', '', '', 'L'], ['', '', 'E', '', 'N', 'N', '']].slice(0, people.length))
const need = needNight ? ['E', 'L', 'N'] : ['E', 'L']
const gaps = DAYS.map((_, d) => need.filter((s) => !g.some((r) => r[d] === s)))
const hours = g.map((r) => r.filter(Boolean).length * 8)
const tap = (p: number, d: number) => setG(g.map((r, i) => (i === p ? r.map((c, j) => (j === d ? CYCLE[(CYCLE.indexOf(c) + 1) % 4] : c)) : r)))
const gapList = gaps.map((x, d) => (x.length ? `${DN[d].slice(0, 3)} ${x.map((s) => NAME[s]).join('+')}` : '')).filter(Boolean)
return (
<div className={['sd-shr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-shr__h">Week 46<span>Tap to cycle shifts</span></div>
<div className="sd-shr__g">
<span>Staff</span>{DAYS.map((d, i) => <span key={i}>{d}</span>)}<span>Hrs</span>
{people.map((p, pi) => [<b key={`n${pi}`}>{p}</b>, ...DAYS.map((_, d) => <button key={`${pi}-${d}`} type="button" className="sd-shr__c" data-s={g[pi][d] || undefined} aria-label={`${p}, ${DN[d]}, ${NAME[g[pi][d]]}`} onClick={() => tap(pi, d)}>{g[pi][d]}</button>), <span key={`h${pi}`} className="sd-shr__hr" data-over={hours[pi] > limit || undefined}>{hours[pi]}</span>])}
<span>Cover</span>{gaps.map((x, d) => <span key={d} className="sd-shr__cov" data-gap={x.length > 0 || undefined}>{x.length ? `−${x.join('')}` : '✓'}</span>)}<span />
</div>
<div className="sd-shr__leg"><span><i data-s="E" />Early</span><span><i data-s="L" />Late</span><span><i data-s="N" />Night</span></div>
<div className="sd-shr__msg" aria-live="polite">{gapList.length ? <>Gaps: <b>{gapList.join(', ')}</b></> : 'Every day is covered.'}{hours.some((h) => h > limit) && <> · {people.filter((_, i) => hours[i] > limit).join(', ')} over {limit} h</>}</div>
</div>
)
}.sd-shr { --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-shr[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-shr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-shr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-shr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-shr { width: min(100%, 350px); padding: 12px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 8px; }
.sd-shr__h { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; padding: 0 2px; }
.sd-shr__h span { font-size: 11px; color: var(--mute); font-weight: 400; }
.sd-shr__g { display: grid; grid-template-columns: 52px repeat(7, minmax(0, 1fr)) 30px; gap: 3px; align-items: center; }
.sd-shr__g > span { font-size: 10px; color: var(--mute); text-align: center; }
.sd-shr__g > span:first-child { text-align: left; }
.sd-shr__g > b { font-size: 11.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-shr__c { height: 30px; border-radius: 6px; font-size: 10.5px; font-weight: 700; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; transition: background-color 120ms; }
.sd-shr__c[data-s='E'] { background: var(--soft) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--fg); }
.sd-shr__c[data-s='L'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-shr__c[data-s='N'] { color: var(--fg) !important; box-shadow: none; outline: 1.5px dashed var(--fg); outline-offset: -2px; }
.sd-shr__hr { font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
.sd-shr__hr[data-over] { font-weight: 700; text-decoration: underline; }
.sd-shr__cov { height: 18px; display: grid; place-items: center; font-size: 10px; color: var(--mute); }
.sd-shr__cov[data-gap] { color: var(--fg); font-weight: 700; }
.sd-shr__msg { font-size: 11.5px; color: var(--mute); min-height: 30px; }
.sd-shr__msg b { color: var(--fg); }
.sd-shr__leg { display: flex; gap: 10px; font-size: 10.5px; color: var(--mute); }
.sd-shr__leg i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 4px; vertical-align: -1px; box-shadow: inset 0 0 0 1px var(--fg); }
.sd-shr__leg i[data-s='L'] { background: var(--fg); }
.sd-shr__leg i[data-s='N'] { box-shadow: none; outline: 1px dashed var(--fg); outline-offset: -1px; }
@media (prefers-reduced-motion: reduce) { .sd-shr *, .sd-shr *::before, .sd-shr *::after { animation: none !important; transition: none !important; } }Overview
Each tap cycles a cell through the shift types. The coverage row flags any day missing a required shift.
Hours per person total in the last column and turn into a warning past the weekly limit.
When to use
- Restaurants, clinics, retail and support team scheduling.
When not to use
- Large rosters with skills and availability: use workforce software.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/shift-roster.json$
npx shadcn@latest add https://sundaeui.dev/r/shift-roster.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/shift-roster.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/shift-roster.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 shift-roster.tsx and shift-roster.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 { ShiftRoster } from '@/components/sundae/shift-roster'
<ShiftRoster />Props
| Prop | Type | Default |
|---|---|---|
peopleStaff names. | string[] | — |
limitWeekly hours before a warning. | number | 40 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each cell is a button named by person, day and shift.
- Coverage gaps are listed as text, not only marked.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-shr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Shift Roster free?
- Yes. Shift Roster is free for personal and commercial projects, with full source on this page.
- How do I install Shift Roster?
- Run npx shadcn@latest add https://sundaeui.dev/r/shift-roster.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Shift Roster support keyboard and screen readers?
- Each cell is a button named by person, day and shift. Coverage gaps are listed as text, not only marked.
- Does Shift Roster respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Week Strip
Forms · RefinedAvailability Grid
Forms · RefinedTimesheet
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined