Phone-style day selector with a sprung pill, swipeable weeks and event dots.
Dates & timemotion
01Preview
- 09:30Standup15 min
- 14:00Design reviewRoom 4B
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { WeekStrip } from "@/components/ui/week-strip";
<WeekStrip
value={day}
onValueChange={setDay}
markers={(date) => eventsOn(date).length}
/>04Source
"use client";
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";
import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ChevronLeft, ChevronRight } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/* ------------------------------------------------------------------------ */
/* Calendar days in local time. Everything is a Date at local midnight. */
const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const keyOf = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const startOfWeek = (d: Date, first: Weekday) => addDays(day(d), -((d.getDay() - first + 7) % 7));
// Where Intl can't say (older engines), these regions start the week on Sunday.
const SUNDAY_FIRST = new Set([
"US",
"CA",
"MX",
"BR",
"AR",
"CO",
"PE",
"VE",
"GT",
"PR",
"JP",
"KR",
"TW",
"HK",
"MO",
"PH",
"TH",
"ID",
"IN",
"IL",
"SA",
"ZA",
"KE",
]);
function firstDayOf(locale: string): Weekday {
try {
const l = new Intl.Locale(locale) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };
const info = l.getWeekInfo?.() ?? l.weekInfo;
if (info?.firstDay) return (info.firstDay % 7) as Weekday;
return SUNDAY_FIRST.has(l.maximize().region ?? "") ? 0 : 1;
} catch {
return 1;
}
}
/* ------------------------------------------------------------------------ */
/* The server knows neither the reader's day nor their locale; both arrive */
/* on the client right after hydration, without a mismatch. */
const noop = () => () => {};
let todayCache: { key: string; date: Date } | null = null;
function readToday() {
const now = day(new Date());
const key = keyOf(now);
if (todayCache?.key !== key) todayCache = { key, date: now };
return todayCache.date;
}
// Rolls over at midnight so "today" is never stale in a tab left open overnight.
function subscribeToday(notify: () => void) {
let timer = 0;
const arm = () => {
const now = new Date();
timer = window.setTimeout(
() => {
notify();
arm();
},
addDays(day(now), 1).getTime() - now.getTime() + 50,
);
};
arm();
const onVisible = () => !document.hidden && notify();
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}
function useToday() {
return useSyncExternalStore(subscribeToday, readToday, () => null);
}
function useLocale(locale?: string) {
const detected = useSyncExternalStore(
noop,
() => Intl.DateTimeFormat().resolvedOptions().locale,
() => "en-US",
);
return locale ?? detected;
}
/* ------------------------------------------------------------------------ */
export type WeekStripProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "children"> & {
/** The selected day. Any time of day is ignored. */
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
/** How many dots to show under a day (up to 3), for days with events. */
markers?: (date: Date) => number;
/** 0 is Sunday. By default it follows the locale. */
weekStartsOn?: Weekday;
/** Earliest selectable day. */
min?: Date;
/** Latest selectable day. */
max?: Date;
isDateDisabled?: (date: Date) => boolean;
/** How a day's dots are read out: "3 events". */
markerLabel?: (count: number) => string;
/** Month label, week arrows and a Today button above the days. */
header?: boolean;
locale?: string;
};
type Nav = { source: "key" | "pointer"; tick: number };
export function WeekStrip({
value,
defaultValue = null,
onValueChange,
markers,
weekStartsOn,
min,
max,
isDateDisabled,
markerLabel = (n) => (n === 1 ? "1 event" : `${n} events`),
header = true,
locale: localeProp,
className,
...rest
}: WeekStripProps) {
const id = useId();
const reduce = useReducedMotion();
const locale = useLocale(localeProp);
const today = useToday();
const first = weekStartsOn ?? firstDayOf(locale);
const [picked, setPicked] = useControllableState<Date | null>({
value,
defaultValue,
onChange: (d) => d && onValueChange?.(d),
});
const selected = picked ? day(picked) : today;
const weekStart = selected ? startOfWeek(selected, first) : null;
const weekKey = weekStart ? keyOf(weekStart) : "pending";
const [nav, setNav] = useState<Nav>({ source: "pointer", tick: 0 });
// Direction of the last week change, so the new week slides in from the side it came from.
const [view, setView] = useState({ key: weekKey, dir: 0 });
if (view.key !== weekKey) setView({ key: weekKey, dir: view.key === "pending" || weekKey === "pending" ? 0 : weekKey > view.key ? 1 : -1 });
const instant = nav.source === "key" || !!reduce;
const rowRef = useRef<HTMLDivElement>(null);
const dragged = useRef(false);
const disabled = (d: Date) => (min != null && d < day(min)) || (max != null && d > day(max)) || !!isDateDisabled?.(d);
const fmt = useMemo(
() => ({
weekday: new Intl.DateTimeFormat(locale, { weekday: "short" }),
full: new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" }),
num: new Intl.DateTimeFormat(locale, { day: "numeric" }),
month: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
range: new Intl.DateTimeFormat(locale, { month: "short", year: "numeric" }),
}),
[locale],
);
const days = weekStart ? Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) : [];
const monthLabel = weekStart ? (weekStart.getMonth() === days[6].getMonth() ? fmt.month.format(weekStart) : fmt.range.formatRange(weekStart, days[6])) : " ";
function select(next: Date, source: Nav["source"]) {
if (disabled(next)) return;
setNav((n) => ({ source, tick: n.tick + 1 }));
if (!selected || keyOf(next) !== keyOf(selected)) setPicked(next);
}
/** Step by `by` days, skipping disabled ones, for arrows and paging. */
function move(by: number, source: Nav["source"]) {
if (!selected) return;
let next = addDays(selected, by);
const unit = Math.sign(by);
for (let i = 0; i < 14 && disabled(next); i++) next = addDays(next, unit);
if (!disabled(next)) select(next, source);
}
/** Previous or next week, keeping the weekday like a phone calendar does. */
function page(dir: 1 | -1, source: Nav["source"]) {
if (!selected || !weekStart) return;
const target = addDays(selected, dir * 7);
if (!disabled(target)) return select(target, source);
const week = Array.from({ length: 7 }, (_, i) => addDays(weekStart, dir * 7 + i));
const open = week.filter((d) => !disabled(d));
if (open.length) select(dir > 0 ? open[0] : open[open.length - 1], source);
}
const canPage = (dir: 1 | -1) => !!weekStart && Array.from({ length: 7 }, (_, i) => addDays(weekStart, dir * 7 + i)).some((d) => !disabled(d));
// Keyboard moves render instantly; once the new day exists, focus follows it.
const selectedKey = selected ? keyOf(selected) : null;
useEffect(() => {
if (nav.source !== "key" || !selectedKey) return;
rowRef.current?.querySelector<HTMLElement>(`[data-date="${selectedKey}"]`)?.focus();
}, [nav, selectedKey]);
function onKeyDown(e: React.KeyboardEvent) {
const map: Record<string, () => void> = {
ArrowRight: () => move(1, "key"),
ArrowLeft: () => move(-1, "key"),
PageDown: () => page(1, "key"),
PageUp: () => page(-1, "key"),
Home: () => weekStart && select(days.find((d) => !disabled(d)) ?? weekStart, "key"),
End: () => weekStart && select([...days].reverse().find((d) => !disabled(d)) ?? days[6], "key"),
};
const run = map[e.key];
if (!run) return;
e.preventDefault();
e.stopPropagation();
run();
}
function onDragEnd(_: unknown, info: PanInfo) {
const swipe = info.offset.x + info.velocity.x * 0.15;
if (swipe < -60 && canPage(1)) page(1, "pointer");
else if (swipe > 60 && canPage(-1)) page(-1, "pointer");
}
const showToday = !!today && !!selected && keyOf(today) !== keyOf(selected);
const slide = {
enter: (c: { dir: number; instant: boolean }) => (c.instant || !c.dir ? { x: 0, opacity: 1 } : { x: `${c.dir * 36}%`, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (c: { dir: number; instant: boolean }) =>
c.instant || !c.dir ? { opacity: 0, transition: { duration: 0 } } : { x: `${c.dir * -36}%`, opacity: 0, transition: { duration: 0.2, ease: ease.out } },
};
const custom = { dir: view.dir, instant };
const iconButton = cn(
"relative grid size-7 place-items-center rounded-md text-fg-2 outline-none",
"transition-[background-color,color,scale,opacity] duration-150 ease-out hover:bg-fg/6 hover:text-fg active:scale-[0.92] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"disabled:pointer-events-none disabled:opacity-35 after:absolute after:-inset-1.5 after:content-['']",
);
return (
<div data-state={selected ? "ready" : "pending"} className={cn("flex w-full min-w-0 flex-col gap-2", className)} {...rest}>
{header && (
<div className="flex h-7 items-center justify-between gap-2">
<div className="relative min-w-0 flex-1 overflow-hidden">
<AnimatePresence initial={false} mode="popLayout" custom={custom}>
<motion.h3
key={monthLabel}
id={`${id}-label`}
custom={custom}
variants={{
enter: (c: typeof custom) => (c.instant || !c.dir ? { opacity: 1 } : { y: c.dir * 8, opacity: 0, filter: "blur(2px)" }),
center: { y: 0, opacity: 1, filter: "blur(0px)" },
exit: (c: typeof custom) =>
c.instant || !c.dir
? { opacity: 0, transition: { duration: 0 } }
: { y: c.dir * -8, opacity: 0, filter: "blur(2px)", transition: { duration: 0.15 } },
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.24, ease: ease.out }}
className="truncate text-[14px] font-medium tracking-[-0.015em] text-fg"
>
{monthLabel}
</motion.h3>
</AnimatePresence>
</div>
<div className="flex shrink-0 items-center gap-1">
<AnimatePresence initial={false}>
{showToday && (
<motion.button
key="today"
type="button"
onClick={() => today && select(today, "pointer")}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.95, transition: { duration: 0.12 } }}
transition={spring.pop}
className={cn(
"mr-1 h-7 rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none",
"transition-[background-color,border-color] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97]",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
)}
>
Today
</motion.button>
)}
</AnimatePresence>
<button type="button" aria-label="Previous week" disabled={!canPage(-1)} onClick={() => page(-1, "pointer")} className={iconButton}>
<ChevronLeft />
</button>
<button type="button" aria-label="Next week" disabled={!canPage(1)} onClick={() => page(1, "pointer")} className={iconButton}>
<ChevronRight />
</button>
</div>
</div>
)}
<div ref={rowRef} className="relative -mx-1 overflow-hidden px-1 py-1">
<AnimatePresence initial={false} mode="popLayout" custom={custom}>
<motion.div
key={weekKey}
role="radiogroup"
aria-labelledby={header ? `${id}-label` : undefined}
aria-label={header ? undefined : "Week"}
custom={custom}
variants={slide}
initial="enter"
animate="center"
exit="exit"
transition={{ x: reduce ? { duration: 0 } : spring.soft, opacity: { duration: 0.18 } }}
drag={selected ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.35}
dragMomentum={false}
onDragStart={() => {
dragged.current = true;
}}
onDragEnd={onDragEnd}
onPointerDownCapture={() => {
dragged.current = false;
}}
onKeyDown={onKeyDown}
className="grid touch-pan-y grid-cols-7 gap-1"
>
{selected
? days.map((d) => {
const k = keyOf(d);
const isSelected = k === keyOf(selected);
const isToday = !!today && k === keyOf(today);
const isPast = !!today && d < today;
const off = disabled(d);
const count = Math.min(3, Math.max(0, markers?.(d) ?? 0));
return (
<button
key={k}
type="button"
role="radio"
aria-checked={isSelected}
aria-current={isToday ? "date" : undefined}
aria-label={count ? `${fmt.full.format(d)}, ${markerLabel(count)}` : fmt.full.format(d)}
tabIndex={isSelected ? 0 : -1}
disabled={off}
data-date={k}
data-selected={isSelected || undefined}
data-today={isToday || undefined}
onClick={() => {
if (!dragged.current) select(d, "pointer");
}}
className={cn(
"group/day relative flex h-[60px] min-w-0 select-none flex-col items-center justify-center gap-1 rounded-xl outline-none",
"transition-[scale,opacity] duration-150 ease-out active:scale-[0.95] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"disabled:pointer-events-none disabled:opacity-35",
)}
>
{isSelected && (
<motion.span
layoutId={`${id}-pill-${weekKey}`}
transition={instant ? { duration: 0 } : spring.snappy}
className="absolute inset-0 rounded-xl bg-fg shadow-[var(--shadow)]"
/>
)}
{!isSelected && (
<span
className={cn(
"absolute inset-0 rounded-xl transition-colors duration-150 group-hover/day:bg-fg/6",
isToday && "border border-line-2",
)}
/>
)}
<span
className={cn(
"relative text-[10.5px] font-medium uppercase leading-none tracking-[0.06em] transition-colors duration-150",
isSelected ? "text-frame/70" : isToday ? "text-fg" : "text-fg-3",
)}
>
{fmt.weekday.format(d)}
</span>
<span
className={cn(
"relative text-[16px] font-medium leading-none tracking-[-0.01em] tabular transition-colors duration-150",
isSelected ? "text-frame" : isPast ? "text-fg-3" : "text-fg",
)}
>
{fmt.num.format(d)}
</span>
<span aria-hidden className="relative flex h-1 items-center gap-[3px]">
{/* A dot added while you watch pops in; dots already there when a week arrives don't. */}
<AnimatePresence initial={false}>
{Array.from({ length: count }, (_, i) => (
<motion.span
key={i}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={spring.pop}
className={cn("size-1 rounded-full transition-colors duration-150", isSelected ? "bg-frame/60" : "bg-fg-3")}
/>
))}
</AnimatePresence>
</span>
</button>
);
})
: Array.from({ length: 7 }, (_, i) => <span key={i} aria-hidden className="h-[60px] rounded-xl bg-fg/[0.03]" />)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Date | null | — | The selected day, controlled. The time of day is ignored. |
| defaultValue | Date | null | null | Uncontrolled starting day. Empty means today. |
| onValueChange | (date: Date) => void | — | Called with the new day at local midnight. |
| markers | (date: Date) => number | — | How many dots to show under a day, up to 3. |
| markerLabel | (count: number) => string | "1 event" / "3 events" | How the dots are read out after the date. |
| weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — | First day of the week, 0 for Sunday. By default it follows the locale. |
| min | Date | — | Earliest selectable day. Weeks entirely before it can't be paged to. |
| max | Date | — | Latest selectable day. |
| isDateDisabled | (date: Date) => boolean | — | Dims and skips individual days, such as closed days. |
| header | boolean | true | Month label, week arrows and a Today button that appears once you leave today. |
| locale | string | — | BCP 47 locale for weekday names, month labels and the first day of the week. Defaults to the reader's. |
06Notes
Behavior
- Changing week keeps the weekday, like a phone calendar: next week from Tuesday lands on Tuesday. If that day is disabled it takes the nearest open day in the week.
- The month label spans weeks that cross months (Sep – Oct 2026), and the week start comes from Intl.Locale week info, with a region fallback for older engines.
- Past days are dimmed, today wears a hairline ring, and today rolls over at midnight in a tab left open overnight.
- A drag only pages past 60px (or a quick flick); a press that turns into a drag never selects the day under it. The arrows do the same job for mouse and keyboard.
- The server can't know the reader's day, so without a value it renders seven blank cells of the final size and fills them in on hydration.
Motion
- The selected pill springs between days on the snappy spring (520 stiffness, 38 damping). One pill per week, so paging never flies it across the strip.
- A new week slides 36% in from the side it came from on the soft spring, the old one leaving the other way in 200ms; the month label rises 8px with a 2px blur. A swipe hands its offset straight to the exit.
- Keyboard moves render instantly, and a dot added while you watch pops in from 0.4 scale. Reduced motion keeps opacity changes only.
Accessibility
- A radiogroup labeled by the month; each day is a radio named with its full date and event count, and today carries aria-current=date.
- One tab stop. Left and Right move a day and cross into the next week, PageUp and PageDown move a week, Home and End go to the ends of the week; focus follows the selection.
- Disabled days are skipped by the arrows and can't be selected. The week arrows are labeled buttons and disable at min and max.