A horizontal 7-day strip with today centered, swipe and arrow paging by week, and a selection pill that glides between days via measured offsets.
npx shadcn@latest add @paragon/week-stripAlso installs: calendar
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
addDays,
compareDay,
dateKey,
MONTH_NAMES,
sameDay,
startOfDay,
} from "@/registry/paragon/ui/calendar";
import { cn } from "@/lib/utils";
const WEEKDAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const WEEKDAY_NAMES = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
export interface WeekStripProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
selected?: Date | null;
defaultSelected?: Date | null;
onSelect?: (date: Date) => void;
/** Reference "today". Pass a fixed date for deterministic renders. */
now?: Date;
/** Center today in the initial window instead of snapping to week start. */
centerToday?: boolean;
/** Week alignment when `centerToday` is false. 0 = Sunday, 1 = Monday. */
weekStartsOn?: 0 | 1;
onWeekChange?: (start: Date) => void;
/** Disables paging and pill motion. */
static?: boolean;
}
/** One 7-day window; owns its measured selection pill. */
function StripWeek({
days,
selected,
today,
animatePill,
onPick,
onKeyNav,
tabbableKey,
onFocusDay,
}: {
days: Date[];
selected: Date | null;
today: Date;
animatePill: boolean;
onPick: (d: Date) => void;
onKeyNav: (e: React.KeyboardEvent, d: Date) => void;
tabbableKey: string;
onFocusDay: (d: Date) => void;
}) {
const rowRef = React.useRef<HTMLDivElement>(null);
const pillRef = React.useRef<HTMLSpanElement>(null);
const positionedRef = React.useRef(false);
const selectedKey = selected ? dateKey(selected) : null;
// Measured technique: read the selected button's offset and translate a
// single pill to it. First paint is suppressed so the pill never flies
// in from zero; later moves retarget mid-flight via the CSS transition.
React.useLayoutEffect(() => {
const row = rowRef.current;
const pill = pillRef.current;
if (!row || !pill) return;
const target = row.querySelector<HTMLElement>('[aria-selected="true"]');
if (!target) {
pill.style.opacity = "0";
positionedRef.current = false;
return;
}
const first = !positionedRef.current;
if (first || !animatePill) pill.style.transition = "none";
pill.style.transform = `translateX(${target.offsetLeft}px)`;
pill.style.width = `${target.offsetWidth}px`;
pill.style.opacity = "1";
if (first || !animatePill) {
void pill.offsetWidth; // flush so the next move transitions
pill.style.transition = "";
positionedRef.current = true;
}
}, [selectedKey, animatePill]);
return (
<div ref={rowRef} className="relative flex gap-1">
<span
ref={pillRef}
aria-hidden
style={{ opacity: 0 }}
className="pointer-events-none absolute inset-y-0 left-0 rounded-lg bg-primary transition-[transform,opacity] duration-200 ease-(--ease-spring) motion-reduce:transition-none"
/>
{days.map((d) => {
const key = dateKey(d);
const isSelected = Boolean(selected && sameDay(d, selected));
const isToday = sameDay(d, today);
return (
<button
key={key}
type="button"
role="option"
data-date={key}
aria-selected={isSelected}
aria-current={isToday ? "date" : undefined}
aria-label={`${WEEKDAY_NAMES[d.getDay()]}, ${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`}
tabIndex={key === tabbableKey ? 0 : -1}
onClick={() => onPick(d)}
onFocus={() => onFocusDay(d)}
onKeyDown={(e) => onKeyNav(e, d)}
className={cn(
"group relative flex h-14 w-11 flex-col items-center justify-center gap-0.5 rounded-lg outline-none transition-colors duration-150 ease-out",
isSelected
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{!isSelected && (
<span
aria-hidden
className="absolute inset-0 rounded-lg bg-accent opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100 group-focus-visible:opacity-100"
/>
)}
<span className="relative z-10 text-[10px] font-medium tracking-wide uppercase">
{WEEKDAY_SHORT[d.getDay()]}
</span>
<span
className={cn(
"relative z-10 text-sm tabular-nums",
(isSelected || isToday) && "font-semibold",
!isSelected && "text-foreground",
)}
>
{d.getDate()}
</span>
<span
aria-hidden
className={cn(
"relative z-10 size-1 rounded-full transition-colors duration-150 ease-out",
isToday
? isSelected
? "bg-primary-foreground"
: "bg-primary"
: "bg-transparent",
)}
/>
</button>
);
})}
</div>
);
}
/**
* A horizontal 7-day strip: today starts centered, arrows and swipes
* page a week at a time with a direction-aware slide, and the selected
* pill glides between days using the measured-offset technique.
*/
export function WeekStrip({
selected: selectedProp,
defaultSelected = null,
onSelect,
now,
centerToday = true,
weekStartsOn = 1,
onWeekChange,
static: isStatic = false,
className,
"aria-label": ariaLabel = "Week",
...props
}: WeekStripProps) {
const reduced = useReducedMotion() ?? false;
const [fallbackToday] = React.useState(() => startOfDay(new Date()));
const today = now ? startOfDay(now) : fallbackToday;
const [selectedState, setSelectedState] = React.useState<Date | null>(
defaultSelected,
);
const selected = selectedProp !== undefined ? selectedProp : selectedState;
const initialStart = React.useMemo(() => {
const anchor = selected ?? today;
return centerToday
? addDays(anchor, -3)
: addDays(anchor, -((anchor.getDay() - weekStartsOn + 7) % 7));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const [start, setStart] = React.useState<Date>(initialStart);
const [dir, setDir] = React.useState(0);
const startKey = dateKey(start);
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
const rootRef = React.useRef<HTMLDivElement>(null);
const focusPendingRef = React.useRef<string | null>(null);
const pageSourceRef = React.useRef<"pointer" | "keyboard">("pointer");
React.useEffect(() => {
const key = focusPendingRef.current;
if (!key) return;
focusPendingRef.current = null;
rootRef.current
?.querySelector<HTMLButtonElement>(
`[data-week="${startKey}"] [data-date="${key}"]`,
)
?.focus();
});
const page = (delta: -1 | 1, source: "pointer" | "keyboard") => {
pageSourceRef.current = source;
setDir(delta);
const next = addDays(start, delta * 7);
setStart(next);
onWeekChange?.(next);
};
const pick = (d: Date) => {
setSelectedState(d);
onSelect?.(d);
};
const onKeyNav = (e: React.KeyboardEvent, d: Date) => {
let target: Date | null = null;
if (e.key === "ArrowLeft") target = addDays(d, -1);
else if (e.key === "ArrowRight") target = addDays(d, 1);
else if (e.key === "Home") target = days[0];
else if (e.key === "End") target = days[6];
if (!target) return;
e.preventDefault();
if (compareDay(target, days[0]) < 0) page(-1, "keyboard");
else if (compareDay(target, days[6]) > 0) page(1, "keyboard");
focusPendingRef.current = dateKey(target);
pick(target);
};
// Swipe paging: capture the pointer, follow with gentle resistance,
// then commit past a 48px threshold.
const [dragX, setDragX] = React.useState(0);
const dragRef = React.useRef<{ id: number; x: number; delta: number } | null>(
null,
);
const monthLabel = (() => {
const a = days[0];
const b = days[6];
const ay = a.getFullYear();
const by = b.getFullYear();
if (a.getMonth() === b.getMonth() && ay === by)
return `${MONTH_NAMES[a.getMonth()]} ${ay}`;
const left = MONTH_NAMES[a.getMonth()].slice(0, 3);
const right = MONTH_NAMES[b.getMonth()].slice(0, 3);
return ay === by ? `${left} – ${right} ${ay}` : `${left} ${ay} – ${right} ${by}`;
})();
const animate = !isStatic && !reduced && pageSourceRef.current === "pointer";
const tabbableKey = (() => {
const inWindow = (d: Date | null) =>
d && days.some((g) => sameDay(g, d)) ? dateKey(d) : null;
return inWindow(selected) ?? inWindow(today) ?? dateKey(days[0]);
})();
return (
<div ref={rootRef} className={cn("w-fit select-none", className)} {...props}>
<div className="flex h-7 items-center justify-between px-1">
<span aria-live="polite" className="text-xs font-medium text-foreground">
{monthLabel}
</span>
<span className="flex items-center gap-0.5">
{([-1, 1] as const).map((d) => (
<button
key={d}
type="button"
aria-label={d === -1 ? "Previous week" : "Next week"}
onClick={() => page(d, "pointer")}
className="pressable relative flex size-6 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color] duration-150 ease-out outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{d === -1 ? (
<ChevronLeft className="size-3.5" aria-hidden />
) : (
<ChevronRight className="size-3.5" aria-hidden />
)}
</button>
))}
</span>
</div>
<div
role="listbox"
aria-label={ariaLabel}
aria-orientation="horizontal"
className="relative touch-pan-y overflow-hidden py-1"
onPointerDown={(e) => {
if (isStatic || e.pointerType === "mouse") return;
dragRef.current = { id: e.pointerId, x: e.clientX, delta: 0 };
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const drag = dragRef.current;
if (!drag || drag.id !== e.pointerId) return;
drag.delta = e.clientX - drag.x;
setDragX(Math.max(-16, Math.min(16, drag.delta * 0.2)));
}}
onPointerUp={(e) => {
const drag = dragRef.current;
if (!drag || drag.id !== e.pointerId) return;
dragRef.current = null;
setDragX(0);
if (Math.abs(drag.delta) > 48) page(drag.delta < 0 ? 1 : -1, "pointer");
}}
onPointerCancel={() => {
dragRef.current = null;
setDragX(0);
}}
>
<div
style={{
transform: `translateX(${dragX}px)`,
transition: dragX === 0 ? "transform 200ms var(--ease-out)" : "none",
}}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={startKey}
data-week={startKey}
initial={{ x: animate ? dir * 24 : 0, opacity: animate ? 0 : 1 }}
animate={{ x: 0, opacity: 1 }}
exit={{
x: animate ? dir * -24 : 0,
opacity: 0,
transition: { duration: animate ? 0.13 : 0, ease: EASE_EXIT },
}}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
<StripWeek
days={days}
selected={selected}
today={today}
animatePill={!isStatic && !reduced}
onPick={pick}
onKeyNav={onKeyNav}
tabbableKey={tabbableKey}
onFocusDay={() => {}}
/>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
);
}