# Calendar Week Strip — React Calendar Component

> A one-week day strip with a selection pill that slides between days; weeks swipe in from the side you navigate and arrow keys roll over into the next week.

- Category: [Calendars & Dates](https://pixelpapa.com/components/calendars.md)
- Tags: calendar, week, strip, schedule, tabs
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/calendars/calendar-week-strip

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/calendar-week-strip.json"
```

The file is written to `components/pixelpapa/calendar-week-strip.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const stamp = (d: Date) => d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate();
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const monday = (d: Date) => addDays(d, -((d.getDay() + 6) % 7));

type WeekStripProps = {
  value?: Date;
  defaultValue?: Date;
  onChange?: (date: Date) => void;
  today?: Date;
  /** How many dots (0–3) to show under a day. */
  markers?: (date: Date) => number;
  className?: string;
};

const DEMO_TODAY = new Date(2026, 2, 14);

export function WeekStrip({ value, defaultValue, onChange, today = DEMO_TODAY, markers, className }: WeekStripProps) {
  const [inner, setInner] = useState(defaultValue ?? today);
  const selected = value ?? inner;
  const [start, setStart] = useState(() => monday(selected));
  const [direction, setDirection] = useState(1);
  const strip = useRef<HTMLDivElement>(null);
  const moveFocus = useRef(false);
  const reduce = useReducedMotion();
  const uid = useId();
  const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
  const mid = days[3];

  useEffect(() => {
    if (!moveFocus.current) return;
    moveFocus.current = false;
    strip.current?.querySelector<HTMLButtonElement>(`[data-week="${stamp(start)}"] [data-day="${stamp(selected)}"]`)?.focus();
  }, [selected, start]);

  function select(day: Date, viaKeyboard = false) {
    const week = monday(day);
    if (stamp(week) !== stamp(start)) {
      setDirection(stamp(week) > stamp(start) ? 1 : -1);
      setStart(week);
    }
    moveFocus.current = viaKeyboard;
    if (value === undefined) setInner(day);
    onChange?.(day);
  }

  function shift(n: number) {
    setDirection(n);
    setStart(addDays(start, 7 * n));
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const moves: Record<string, Date> = {
      ArrowLeft: addDays(selected, -1),
      ArrowRight: addDays(selected, 1),
      Home: monday(selected),
      End: addDays(monday(selected), 6),
      PageUp: addDays(selected, -7),
      PageDown: addDays(selected, 7),
    };
    if (e.key in moves) {
      e.preventDefault();
      select(moves[e.key], true);
    }
  }

  const inView = days.some((d) => stamp(d) === stamp(selected));

  return (
    <div className={cn("w-full", className)}>
      <div className="flex items-center gap-1">
        <p className="flex-1 text-sm font-semibold text-foreground" aria-live="polite">
          {MONTHS[mid.getMonth()]} <span className="font-normal text-muted-foreground">{mid.getFullYear()}</span>
        </p>
        {stamp(monday(today)) !== stamp(start) && (
          <button
            type="button"
            onClick={() => select(today)}
            className="mr-1 h-7 rounded-lg px-2 text-xs font-medium text-primary transition-colors hover:bg-primary/10"
          >
            Today
          </button>
        )}
        {[-1, 1].map((d) => (
          <button
            key={d}
            type="button"
            aria-label={d < 0 ? "Previous week" : "Next week"}
            onClick={() => shift(d)}
            className="flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
          >
            {d < 0 ? <ChevronLeft className="size-4" /> : <ChevronRight className="size-4" />}
          </button>
        ))}
      </div>

      <div ref={strip} role="radiogroup" aria-label="Day" onKeyDown={onKeyDown} className="relative mt-3 overflow-hidden rounded-2xl bg-muted p-1">
        <AnimatePresence mode="popLayout" initial={false} custom={direction}>
          <motion.div
            key={stamp(start)}
            data-week={stamp(start)}
            custom={direction}
            variants={{
              enter: (d: number) => (reduce ? { opacity: 0 } : { x: `${d * 100}%`, opacity: 0 }),
              center: { x: 0, opacity: 1 },
              exit: (d: number) => (reduce ? { opacity: 0 } : { x: `${d * -100}%`, opacity: 0 }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{ type: "spring", stiffness: 320, damping: 34 }}
            className="grid grid-cols-7 gap-1"
          >
            {days.map((day, i) => {
              const on = stamp(day) === stamp(selected);
              const isToday = stamp(day) === stamp(today);
              const dots = Math.min(3, markers?.(day) ?? 0);
              return (
                <button
                  key={stamp(day)}
                  type="button"
                  role="radio"
                  data-day={stamp(day)}
                  aria-checked={on}
                  aria-label={`${DAYS[day.getDay()]} ${MONTHS[day.getMonth()]} ${day.getDate()}${dots ? `, ${dots} events` : ""}`}
                  tabIndex={on || (!inView && i === 0) ? 0 : -1}
                  onClick={() => select(day)}
                  className="relative flex h-[76px] flex-col items-center justify-center gap-1 rounded-xl outline-offset-2"
                >
                  {on && (
                    <motion.span
                      layoutId={`${uid}-pill-${stamp(start)}`}
                      transition={{ type: "spring", stiffness: 440, damping: 32 }}
                      className="absolute inset-0 rounded-xl bg-background shadow-sm ring-1 ring-border"
                    />
                  )}
                  <span className={cn("relative text-[11px] font-medium uppercase", on ? "text-primary" : "text-muted-foreground")}>
                    {DAYS[day.getDay()]}
                  </span>
                  <span
                    className={cn(
                      "relative flex size-7 items-center justify-center rounded-full text-base tabular-nums",
                      on ? "font-semibold text-foreground" : "text-foreground/80",
                      isToday && "bg-primary font-semibold text-primary-foreground",
                    )}
                  >
                    {day.getDate()}
                  </span>
                  <span className="relative flex h-1 gap-0.5" aria-hidden>
                    {Array.from({ length: dots }, (_, n) => (
                      <span key={n} className={cn("size-1 rounded-full", on ? "bg-primary" : "bg-muted-foreground/50")} />
                    ))}
                  </span>
                </button>
              );
            })}
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  );
}

const sessions = ["Morning swim", "Design critique", "Lunch with Noor", "Sprint planning", "Investor update", "Yoga", "1:1 with Jonas"];
const times = ["7:30", "10:00", "12:30", "14:00", "16:30"];
// Deterministic "events" derived from the date, so the demo needs no data.
const countFor = (d: Date) => (d.getDate() * 7 + d.getMonth() * 3) % 4;

export default function CalendarWeekStripDemo() {
  const [day, setDay] = useState(new Date(2026, 2, 12));
  const count = countFor(day);

  return (
    <div className="w-full max-w-md rounded-2xl border border-border bg-card p-5 shadow-sm">
      <WeekStrip value={day} onChange={setDay} markers={countFor} />
      <div className="mt-4 h-[132px] overflow-hidden">
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.ul
            key={stamp(day)}
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -10 }}
            transition={{ type: "spring", stiffness: 400, damping: 34 }}
            className="space-y-1.5"
          >
            {Array.from({ length: count }, (_, i) => (
              <li key={i} className="flex h-10 items-center gap-3 rounded-xl bg-muted/60 px-3 text-sm">
                <span className="w-10 text-xs font-medium text-muted-foreground tabular-nums">{times[(day.getDate() + i * 2) % 5]}</span>
                <span className="h-4 w-0.5 rounded-full bg-primary" />
                <span className="text-card-foreground">{sessions[(day.getDate() + i * 3) % sessions.length]}</span>
              </li>
            ))}
            {count === 0 && (
              <li className="flex h-[132px] items-center justify-center rounded-xl border border-dashed border-border text-sm text-muted-foreground">
                Nothing planned. Enjoy the quiet.
              </li>
            )}
          </motion.ul>
        </AnimatePresence>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Calendar Week Strip" component from PixelPapa UI to this project.

What it is: A one-week day strip with a selection pill that slides between days; weeks swipe in from the side you navigate and arrow keys roll over into the next week.
Category: Calendars & Dates. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/calendar-week-strip.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/calendar-week-strip.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/calendar-week-strip" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More calendars & dates

- [Calendar Event Dots](https://pixelpapa.com/components/calendars/calendar-event-dots.md) (Pro): A month grid with coloured dots for each day's events beside an agenda that re-staggers for the selected day; months slide and arrow keys move the selection.
- [Calendar Heatmap Year](https://pixelpapa.com/components/calendars/calendar-heatmap-year.md): A contribution-style year heatmap: weeks ripple in column by column, and one tooltip springs from cell to cell under the pointer or the arrow keys.
- [Calendar Month Basic](https://pixelpapa.com/components/calendars/calendar-month-basic.md) (Pro): A single-month calendar where months slide in from the direction you navigate, the selection pill glides between days, and the grid is fully arrow-key driven.
- [Calendar Range Picker](https://pixelpapa.com/components/calendars/calendar-range-picker.md) (Pro): Two months side by side for picking a date range: click a start, and a soft band previews the span under your cursor until the second click locks it in.
- [Date Picker Popover](https://pixelpapa.com/components/calendars/date-picker-popover.md) (Pro): A date field you can type into or open as a popover with quick presets and a sliding month grid; picking a day springs the popover shut and fills the field.
- [Time Picker Wheel](https://pixelpapa.com/components/calendars/time-picker-wheel.md): Hour, minute and AM/PM drums you scroll, flick or drag; rows curve away like a cylinder, snap into the centre band, and arrow keys step each wheel.
