Week Strip
Inputs & Forms

Week Strip

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.

Install

npx shadcn@latest add @paragon/week-strip

Also installs: calendar

week-strip.tsx

"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>
  );
}