Week Strip
A one-week date strip: swipe or arrow to the next week, tap a day, dots mark days with events.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/week-strip.json'use client'
import { useRef, useState } from 'react'
import './week-strip.css'
export interface WeekStripProps {
value?: Date
defaultValue?: Date
onValueChange?: (d: Date) => void
events?: Record<string, number>
weekStartsOn?: 0 | 1
fill?: 'dark' | 'light'
className?: string
}
const key = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const add = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
const startOf = (d: Date, ws: number) => add(d, -((d.getDay() - ws + 7) % 7))
const same = (a: Date, b: Date) => key(a) === key(b)
/** Swipeable one-week date strip. */
export function WeekStrip({ value, defaultValue, onValueChange, events = {}, weekStartsOn = 1, fill = 'dark', className }: WeekStripProps) {
const today = new Date()
const [inner, setInner] = useState(defaultValue ?? today)
const sel = value ?? inner
const [dir, setDir] = useState<'next' | 'prev'>('next')
const swipe = useRef<number | null>(null)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const start = startOf(sel, weekStartsOn)
const days = Array.from({ length: 7 }, (_, i) => add(start, i))
const pick = (d: Date, focus?: boolean) => {
if (startOf(d, weekStartsOn).getTime() !== start.getTime()) setDir(d > sel ? 'next' : 'prev')
setInner(d); onValueChange?.(d)
if (focus) requestAnimationFrame(() => refs.current[(d.getDay() - weekStartsOn + 7) % 7]?.focus())
}
const fmt = (d: Date, o: Intl.DateTimeFormatOptions) => d.toLocaleDateString('en-GB', o)
const n = events[key(sel)] ?? 0
const arr = (p: string, lab: string, delta: number) => (
<button type="button" className="sd-wks__arr" aria-label={lab} onClick={() => pick(add(sel, delta))}><svg viewBox="0 0 16 16" aria-hidden><path d={p} /></svg></button>
)
return (
<div
className={['sd-wks', className].filter(Boolean).join(' ')}
data-fill={fill}
onPointerDown={(e) => { swipe.current = e.clientX }}
onPointerUp={(e) => {
if (swipe.current === null) return
const dx = e.clientX - swipe.current
swipe.current = null
if (Math.abs(dx) > 50) pick(add(sel, dx < 0 ? 7 : -7))
}}
>
<div className="sd-wks__top">
<span className="sd-wks__mo" aria-live="polite">{fmt(start, { month: 'long', year: 'numeric' })}</span>
<div className="sd-wks__nav">{arr('M10 3.5 5.5 8l4.5 4.5', 'Previous week', -7)}{arr('M6 3.5 10.5 8 6 12.5', 'Next week', 7)}</div>
</div>
<div key={key(start)} className="sd-wks__row" data-dir={dir} role="radiogroup" aria-label="Day">
{days.map((d, i) => {
const c = Math.min(3, events[key(d)] ?? 0)
const on = same(d, sel)
return (
<button
key={key(d)}
ref={(el) => { refs.current[i] = el }}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
aria-label={`${fmt(d, { weekday: 'long', day: 'numeric', month: 'long' })}${c ? `, ${events[key(d)]} events` : ''}`}
className="sd-wks__d"
data-today={same(d, today) || undefined}
onClick={() => pick(d)}
onKeyDown={(e) => {
const m: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, PageDown: 7, PageUp: -7 }
if (e.key in m) { e.preventDefault(); pick(add(sel, m[e.key]), true) }
}}
>
<span className="sd-wks__w">{fmt(d, { weekday: 'short' }).slice(0, 2)}</span>
<span className="sd-wks__n">{d.getDate()}</span>
<span className="sd-wks__dots" aria-hidden>{Array.from({ length: c }, (_, j) => <i key={j} />)}</span>
</button>
)
})}
</div>
<div className="sd-wks__sel"><b>{fmt(sel, { weekday: 'long', day: 'numeric', month: 'long' })}</b> · {n ? `${n} event${n > 1 ? 's' : ''}` : 'Nothing scheduled'}</div>
</div>
)
}.sd-wks { --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-wks[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-wks button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-wks :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-wks .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-wks { width: min(100%, 330px); padding: 12px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; touch-action: pan-y; }
.sd-wks__top { display: flex; align-items: center; justify-content: space-between; padding: 0 2px 10px; }
.sd-wks__mo { font-weight: 600; font-size: 14px; }
.sd-wks__nav { display: flex; gap: 2px; }
.sd-wks__arr { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; color: var(--mute); }
.sd-wks__arr:hover { color: var(--fg); background: var(--soft) !important; }
.sd-wks__arr svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.sd-wks__row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; animation: sd-wks-in 320ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-wks__row[data-dir='prev'] { animation-name: sd-wks-in-prev; }
@keyframes sd-wks-in { from { transform: translateX(30%); opacity: 0; } }
@keyframes sd-wks-in-prev { from { transform: translateX(-30%); opacity: 0; } }
.sd-wks__d { display: grid; justify-items: center; gap: 4px; padding: 8px 0 7px; border-radius: 12px; transition: background-color 180ms, color 180ms; }
.sd-wks__d:hover { background: var(--soft) !important; }
.sd-wks__d[aria-checked='true'] { background: var(--strong) !important; color: var(--on); }
.sd-wks__w { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.6; }
.sd-wks__n { font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-wks__d[data-today] .sd-wks__n { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1.5px; }
.sd-wks__dots { display: flex; gap: 2px; height: 4px; }
.sd-wks__dots i { width: 4px; height: 4px; border-radius: 9px; background: currentColor; opacity: 0.7; }
.sd-wks__sel { margin-top: 12px; padding: 10px 4px 0; border-top: 1px solid var(--line); font-size: 13px; color: var(--mute); }
.sd-wks__sel b { color: var(--fg); font-weight: 600; }
@media (prefers-reduced-motion: reduce) { .sd-wks *, .sd-wks *::before, .sd-wks *::after { animation: none !important; transition: none !important; } }Overview
Seven days in a row with the selected day filled and today underlined. The arrows (or a horizontal swipe) slide the whole week in from the side.
Small dots under a day show how many things are scheduled, up to three. Below, the selected date is spelled out.
When to use
- Agenda, habit, fitness and booking screens on mobile.
When not to use
- Picking dates far away: use a month calendar.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/week-strip.json$
npx shadcn@latest add https://sundaeui.dev/r/week-strip.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/week-strip.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/week-strip.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 week-strip.tsx and week-strip.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 { WeekStrip } from '@/components/sundae/week-strip'
<WeekStrip />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValueSelected day. | Date | — |
onValueChangeCalled on change. | (d: Date) => void | — |
eventsEvent counts by YYYY-MM-DD. | Record<string, number> | — |
weekStartsOnSunday or Monday. | 0 | 1 | 1 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Days form a radiogroup with full date labels such as "Thursday 12 November, 2 events".
- Arrow keys move by a day and cross into the next week.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-wks; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next day
- Page Up / Down
- Previous / next week
FAQ
- Is Week Strip free?
- Yes. Week Strip is free for personal and commercial projects, with full source on this page.
- How do I install Week Strip?
- Run npx shadcn@latest add https://sundaeui.dev/r/week-strip.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Week Strip support keyboard and screen readers?
- Days form a radiogroup with full date labels such as "Thursday 12 November, 2 events". Arrow keys move by a day and cross into the next week.
- Does Week Strip respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Time Slots
Forms · RefinedRepeat Picker
Forms · Refined- Pro
Date Range Presets
Forms · Refined Cron Builder
Forms · RefinedAvailability Grid
Forms · RefinedMonth Picker
Forms · Refined