# Weekly hours

> Weekdays with switches: switch one on and it opens on its time ranges, with a smooth fold for every range added or removed.

An availability editor. Each weekday has a switch; switching one on lifts it into a card and opens it on its time ranges (From and To, with a button to remove each) and “Add more”. Ranges open from nothing and fold away, and switching the day off folds it shut.

A React component by Bruno Delorence: https://brunodelorence.com/components/weekly-hours. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Anatomy

1. **Caption**: Names the list (`label`). The days are a group labelled by it, so a screen reader hears “Availability, group”.
2. **Card**: Holds the days, one row each, on a surface a step up from the dotted stage.
3. **Day on**: A switched-on day lifts into its own card with a shadow, and its body opens from 0fr to 1fr to the height of its ranges.
4. **Switch**: A checkbox with `role="switch"`: Space or Enter flips it, and the knob slides on a bouncy spring.
5. **Time**: From and To are native selects in half-hours. Moving one end pushes the other, so a range always ends after it starts.
6. **Remove**: Folds its range away; focus moves to the day’s Add more, as the button it was on is gone.
7. **Add more**: Adds a range an hour after the last, two hours long. It turns faint and disabled once no range fits before midnight.
8. **Day off**: A closed day is a single row, its name and its switch; the hours it had are kept for when it comes back on.

## When to use

- Setting recurring availability: the hours someone takes bookings, a shop’s opening hours, when a support line is staffed.
- Weeks where most days are off or alike, so a closed day costs one row and only the days in use take room.
- Days that need more than one window, like a morning and an afternoon with a lunch break between: each day takes several ranges.
- Settings edited now and then and read often, where seeing the whole week at a glance matters more than speed of entry.

## When not to use

- One-off dates or exceptions like a holiday. Use a date picker, or a list of exceptions beside the weekly hours.
- Times finer than half an hour, or overnight ranges past midnight: the selects go by half-hours and a range ends the day it starts.
- Comparing many people’s availability at once. A calendar grid shows overlaps; this edits one person’s week.
- A single on/off choice per day with no hours. A row of day toggles or checkboxes is lighter.

## Installation

Install it with the shadcn CLI, into components/weekly-hours of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/weekly-hours.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
'use client';

import { WeeklyHours } from './weekly-hours';
import { WEEK } from './week';

export function Availability() {
  return <WeeklyHours week={WEEK} autoplay={false} onChange={(week) => console.log(week)} />;
}
```

## States

- **All days off**: Every day is a closed row: just its name and its switch.
- **One day on**: The day lifts into a card and opens on its range, with Add more under it.
- **Several ranges**: A split day: each range has its own From, To and remove button.
- **On, no hours**: What is left after removing a day’s last range: the day stays on, with only Add more.
- **No room left**: The last range ends after 10:00 PM, so no new one fits: Add more is disabled.
- **Dark**: With `colorScheme="dark"`; an open day’s switch turns light with a dark knob.

## How it works

- A day’s body is a grid with one row that goes from 0fr to 1fr. It animates to the height of its content, whatever that is, and the days below make room as it opens.
- A range that is added or removed opens or closes the same way, with the Web Animations API. A removed range leaves the list once it has folded.
- The content fades in a beat after the fold starts, with a little lift, and leaves quickly. The switch’s knob slides on a bouncy spring and stretches while pressed.
- The times are selects in half-hours, and moving one end of a range pushes the other, so a range always ends after it starts. A new range starts an hour after the last one.
- The list hangs from the top of its stage, so a day opening never moves the days above it out from under the pointer.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. At night a switched-on day shows a light track with a dark knob, since a white knob would vanish on it.

## API reference

- **week** (`WeekDay[]`, default `WEEK`): The days in order: a name, whether it is on, and its ranges as { from, to } in minutes after midnight.
- **label** (`string`, default `'Availability'`): The list’s caption and accessible name.
- **onChange** (`(week: WeekDay[]) => void`): Called with the whole week after every change.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo cursor switches a day on, adds a range, removes it and switches the day off.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- Each switch is a checkbox with the switch role, named by its day. Space or Enter toggles it, and so does a press anywhere on the day’s header.
- Each day is a labelled group, and a closed day’s controls are inert, so Tab only visits what is open. The times are native selects named From and To; the remove buttons say which range they remove.
- Changes are announced, for example “Wednesday on, 9:00 AM to 5:00 PM.” Removing a range moves focus to that day’s “Add more”.
- Under reduced motion days and ranges open and close at once, and the demo does not play.

## Source

### weekly-hours/weekly-hours.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import { DemoCursor, playDemo } from './demo-cursor';
import { easingOf, spring } from './spring';
import { useAutoplay } from './use-autoplay';
import {
  ENDS,
  FIRST_RANGE,
  STARTS,
  WEEK,
  clock,
  moveEnd,
  nextRange,
  spoken,
  type TimeRange,
  type WeekDay,
} from './week';
import styles from './weekly-hours.module.css';

/*
 * Weekly hours: the weekdays, each with a switch. Switching a day on opens it on its time ranges,
 * each with From, To and a button to remove it, and "Add more"; switching it off folds it away.
 *
 * - A day's body is a grid of one row that goes from 0fr to 1fr: it animates to the height of
 *   its content, whatever that is, and the days below make room as it opens. Its content fades
 *   in a beat later, and leaves quickly.
 * - A range that is added or removed opens or closes the same way, with the Web Animations API;
 *   a removed one leaves the list once it has folded.
 * - The switches are checkboxes with the switch role, and the times are selects in half-hours:
 *   moving one end of a range pushes the other, so a range always ends after it starts.
 * - While on screen and untouched, a demo cursor switches a day on, adds a range, removes it and
 *   switches the day off again.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

type Slot = TimeRange & { id: number };
type Day = { name: string; on: boolean; ranges: Slot[] };

type DemoAct =
  | { type: 'toggle'; day: number }
  | { type: 'add'; day: number }
  | { type: 'remove'; day: number; id: number };

/** A day or a range opening and closing: no bounce, as a height cannot overshoot its content. */
const FOLD = spring(0, 440);
/** The switch's knob: a little bounce. */
const KNOB = spring(0.3, 340);
/** The day the demo plays with. */
const DEMO_DAY = 2;

const withIds = (week: WeekDay[]): Day[] => {
  let id = 0;
  return week.map((day) => ({
    ...day,
    ranges: day.ranges.map((range) => ({ ...range, id: ++id })),
  }));
};

const plain = (days: Day[]): WeekDay[] =>
  days.map(({ name, on, ranges }) => ({
    name,
    on,
    ranges: ranges.map(({ from, to }) => ({ from, to })),
  }));

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function WeeklyHours({
  week = WEEK,
  label = 'Availability',
  onChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The days, in order: a name, whether it is on, and its ranges in minutes after midnight. */
  week?: WeekDay[];
  /** The list's caption, and its accessible name. */
  label?: string;
  /** Called with the whole week after every change. */
  onChange?: (week: WeekDay[]) => void;
  /** While on screen and untouched, a demo cursor switches a day on and plays with its hours. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [days, setDays] = useState(() => withIds(week));
  /** Ranges folding away, on their way out of the list. */
  const [leaving, setLeaving] = useState<ReadonlySet<number>>(() => new Set());
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const labelId = useId();
  const cursor = useRef<HTMLSpanElement>(null);
  const nextId = useRef(week.reduce((count, day) => count + day.ranges.length, 0) + 1);
  /** Ranges just added, to open once they are on the page. */
  const entering = useRef(new Set<number>());

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  /** Changes one day, and reports the week. */
  const change = (index: number, next: (day: Day) => Day) => {
    const updated = days.map((day, i) => (i === index ? next(day) : day));
    setDays(updated);
    onChange?.(plain(updated));
  };

  const toggle = (index: number, byReader: boolean) => {
    const day = days[index];
    const on = !day.on;
    // Switched on with no hours: a first range to start from.
    const ranges =
      on && !day.ranges.length ? [{ ...FIRST_RANGE, id: nextId.current++ }] : day.ranges;
    change(index, () => ({ ...day, on, ranges }));
    if (byReader) announce(on ? `${day.name} on, ${spoken(ranges)}.` : `${day.name} off.`);
  };

  const add = (index: number, byReader: boolean) => {
    const day = days[index];
    const range = nextRange(day.ranges.filter((slot) => !leaving.has(slot.id)));
    if (!range) return;
    const slot = { ...range, id: nextId.current++ };
    entering.current.add(slot.id);
    change(index, () => ({ ...day, ranges: [...day.ranges, slot] }));
    if (byReader) announce(`Added ${spoken([range])} on ${day.name}.`);
  };

  const remove = (index: number, id: number, byReader: boolean) => {
    const day = days[index];
    const range = day.ranges.find((slot) => slot.id === id);
    if (!range || leaving.has(id)) return;
    const kept = day.ranges.filter((slot) => slot.id !== id);
    onChange?.(plain(days.map((other, i) => (i === index ? { ...other, ranges: kept } : other))));
    const drop = () => {
      setDays((now) =>
        now.map((other) =>
          other.name === day.name
            ? { ...other, ranges: other.ranges.filter((slot) => slot.id !== id) }
            : other,
        ),
      );
      setLeaving((now) => new Set([...now].filter((other) => other !== id)));
    };
    const item = root.current?.querySelector<HTMLElement>(`[data-range='${id}']`);
    if (item && !reducedMotion()) {
      setLeaving((now) => new Set(now).add(id));
      item
        .animate(
          [
            { gridTemplateRows: '1fr', opacity: 1 },
            { gridTemplateRows: '0fr', opacity: 0 },
          ],
          { duration: FOLD.duration, easing: easingOf(FOLD), fill: 'forwards' },
        )
        .finished.then(drop, () => {});
    } else drop();
    if (byReader) {
      announce(`Removed ${spoken([range])} from ${day.name}.`);
      // Its button is going: focus moves to the day's Add more.
      root.current?.querySelector<HTMLElement>(`[data-day='${index}'] [data-add]`)?.focus();
    }
  };

  const setTime = (index: number, id: number, end: keyof TimeRange, minutes: number) =>
    change(index, (day) => ({
      ...day,
      ranges: day.ranges.map((slot) =>
        slot.id === id ? { ...slot, ...moveEnd(slot, end, minutes) } : slot,
      ),
    }));

  // A range just added opens from nothing, its row fading in a beat later.
  useLayoutEffect(() => {
    if (!entering.current.size) return;
    if (!reducedMotion()) {
      for (const id of entering.current) {
        const item = root.current?.querySelector<HTMLElement>(`[data-range='${id}']`);
        item?.animate([{ gridTemplateRows: '0fr' }, { gridTemplateRows: '1fr' }], {
          duration: FOLD.duration,
          easing: easingOf(FOLD),
        });
        item?.firstElementChild?.animate(
          [
            { opacity: 0, transform: 'translateY(-8px)', filter: 'blur(3px)' },
            { opacity: 1, transform: 'none', filter: 'none' },
          ],
          { duration: 420, delay: 70, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', fill: 'backwards' },
        );
      }
    }
    entering.current.clear();
  }, [days, root]);

  // The demo: the cursor switches a day on, adds a range, removes it and switches the day off.
  const readDays = useEffectEvent(() => days);
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'toggle') toggle(act.day, false);
    else if (act.type === 'add') add(act.day, false);
    else remove(act.day, act.id, false);
  });
  const demo = autoplay && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const arrow = cursor.current;
    if (!demo || !stage || !arrow) return;
    return playDemo(stage, arrow, async ({ wait, moveTo, press, leave }) => {
      const index = Math.min(DEMO_DAY, readDays().length - 1);
      const find = (selector: string) => stage.querySelector(`[data-day='${index}'] ${selector}`);
      for (let turn = 0; ; turn += 1) {
        await wait(turn ? 1800 : 1000);
        const toggleInput = find("[role='switch']");
        const addButton = find('[data-add]');
        if (!toggleInput || !addButton) return;
        // Left on by a reader: switch it off first.
        if (readDays()[index].on) {
          await moveTo(toggleInput);
          await press(toggleInput, () => demoAct({ type: 'toggle', day: index }));
          await wait(1000);
        }
        await moveTo(toggleInput);
        await press(toggleInput, () => demoAct({ type: 'toggle', day: index }));
        await wait(900);
        const before = readDays()[index].ranges.map((range) => range.id);
        await moveTo(addButton);
        await press(addButton, () => demoAct({ type: 'add', day: index }));
        await wait(1400);
        // Only the range it added.
        const added = readDays()[index].ranges.find((range) => !before.includes(range.id));
        const removeButton = added && find(`[data-range='${added.id}'] [data-remove]`);
        if (added && removeButton) {
          await moveTo(removeButton);
          await press(removeButton, () => demoAct({ type: 'remove', day: index, id: added.id }));
          await wait(1000);
        }
        await moveTo(toggleInput);
        await press(toggleInput, () => demoAct({ type: 'toggle', day: index }));
        await wait(300);
        await leave();
      }
    });
  }, [demo, root]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={
        {
          '--fold': FOLD.easing,
          '--fold-ms': `${FOLD.duration}ms`,
          '--knob': KNOB.easing,
          '--knob-ms': `${KNOB.duration}ms`,
        } as CSSProperties
      }
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div className={styles.body}>
        <span id={labelId} className={styles.caption} data-part="caption">
          {label}
        </span>
        <div className={styles.panel} data-part="panel" role="group" aria-labelledby={labelId}>
          {days.map((day, index) => {
            const nameId = `${labelId}-${index}`;
            const room = !!nextRange(day.ranges.filter((slot) => !leaving.has(slot.id)));
            return (
              <div
                key={day.name}
                className={styles.day}
                data-part="day"
                data-day={index}
                data-on={day.on || undefined}
                role="group"
                aria-labelledby={nameId}
              >
                <label className={styles.header} data-part="header">
                  <span id={nameId} className={styles.name} data-part="name">
                    {day.name}
                  </span>
                  <input
                    type="checkbox"
                    role="switch"
                    className={styles.switchInput}
                    checked={day.on}
                    onChange={() => toggle(index, true)}
                    onKeyDown={(event) => {
                      // Enter as well as Space, as on a button.
                      if (event.key !== 'Enter') return;
                      event.preventDefault();
                      toggle(index, true);
                    }}
                  />
                  <span className={styles.switch} data-part="switch" aria-hidden="true">
                    <span className={styles.knob} />
                  </span>
                </label>

                <div className={styles.fold} inert={!day.on}>
                  <div className={styles.foldInner}>
                    <div className={styles.content}>
                      <ul className={styles.ranges} aria-label={`${day.name} hours`}>
                        {day.ranges.map((range) => (
                          <li
                            key={range.id}
                            className={styles.range}
                            data-part="range"
                            data-range={range.id}
                            inert={leaving.has(range.id)}
                          >
                            <div className={styles.rangeInner}>
                              <div className={styles.rangeRow}>
                                {(['from', 'to'] as const).map((end) => (
                                  <label key={end} className={styles.time}>
                                    <span className={styles.timeLabel}>
                                      {end === 'from' ? 'From' : 'To'}
                                    </span>
                                    <select
                                      className={styles.select}
                                      data-part="time"
                                      value={range[end]}
                                      onChange={(event) =>
                                        setTime(index, range.id, end, Number(event.target.value))
                                      }
                                    >
                                      {(end === 'from' ? STARTS : ENDS).map((minutes) => (
                                        <option key={minutes} value={minutes}>
                                          {clock(minutes)}
                                        </option>
                                      ))}
                                    </select>
                                  </label>
                                ))}
                                <button
                                  type="button"
                                  className={styles.remove}
                                  data-part="remove"
                                  data-remove=""
                                  aria-label={`Remove ${spoken([range])}`}
                                  onClick={() => remove(index, range.id, true)}
                                >
                                  <svg viewBox="0 0 16 16" aria-hidden="true">
                                    <path d="m4.75 4.75 6.5 6.5m0-6.5-6.5 6.5" />
                                  </svg>
                                </button>
                              </div>
                            </div>
                          </li>
                        ))}
                      </ul>
                      <button
                        type="button"
                        className={styles.add}
                        data-part="add"
                        data-add=""
                        disabled={!room}
                        onClick={() => add(index, true)}
                      >
                        <svg viewBox="0 0 16 16" aria-hidden="true">
                          <path d="M8 3.5v9M3.5 8h9" />
                        </svg>
                        Add more
                      </button>
                    </div>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
      <DemoCursor ref={cursor} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### weekly-hours/weekly-hours.module.css

```css
/* Weekly hours (weekly-hours.tsx): a light stage with the days on a white card, or at night a
   near-black stage with the days on a dark grey one. A day that is switched on carries data-on,
   which opens its body and lifts it.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). */

.root {
  /* The stage, its dots and its edge; the card a step up, with a hairline at night only. */
  --surface: #e4e4e7;
  --dots: rgb(0 0 0 / 0.055);
  --line: rgb(0 0 0 / 0.06);
  --surface-2: #fff;
  --card-line: transparent;
  /* A day switched off, then under the pointer; a day switched on, lifted, with its hairline. */
  --fill: #f3f3f5;
  --fill-hover: #eeeef1;
  --day: #fff;
  --day-line: #e6e6ea;
  /* The times and Add more in an open day, then under the pointer. */
  --field: #f3f3f5;
  --field-hover: #ebebee;
  /* Words: an open day's name and the times; a closed day's name and Add more; the caption, on
     the stage; From and To; the remove cross and Add more when there is no room. */
  --ink: #1d1d1f;
  --text: #48484a;
  --label: #636366;
  --ink-muted: #6e6e73;
  --faint: #aeaeb2;
  /* The switch: its track off and on, its knob off and on. Light keeps a white knob on a black
     track; at night the track turns light when on, and the knob dark on it. */
  --track: #dcdce1;
  --accent: #1d1d1f;
  --knob-fill: #fff;
  --accent-ink: #fff;
  /* The card's shade near and far, an open day's, and the knob's. */
  --shadow-near: rgb(0 0 0 / 0.04);
  --shadow: rgb(0 0 0 / 0.14);
  --day-shadow: rgb(0 0 0 / 0.28);
  --knob-shadow: rgb(0 0 0 / 0.22);
  --knob-edge: rgb(0 0 0 / 0.04);
  --focus: #0a84ff;
  /* The demo's cursor looks like the system's on both: a dark arrow with a white edge. Its press
     ring is dark on the light stage and light on the dark one. */
  --pointer: #1d1d1f;
  --pointer-edge: #fff;
  --pointer-ring: rgb(0 0 0 / 0.14);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font:
    400 15px/1.3 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#e4e4e7, #111113);
    --dots: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.05));
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --surface-2: light-dark(#fff, #1c1c1e);
    --card-line: light-dark(transparent, rgb(255 255 255 / 0.06));
    --fill: light-dark(#f3f3f5, #242426);
    --fill-hover: light-dark(#eeeef1, #29292c);
    --day: light-dark(#fff, #2c2c2e);
    --day-line: light-dark(#e6e6ea, rgb(255 255 255 / 0.08));
    --field: light-dark(#f3f3f5, #3a3a3c);
    --field-hover: light-dark(#ebebee, #444447);
    --ink: light-dark(#1d1d1f, #f5f5f7);
    --text: light-dark(#48484a, #bdbdc2);
    --label: light-dark(#636366, #8e8e93);
    --ink-muted: light-dark(#6e6e73, #98989d);
    --faint: light-dark(#aeaeb2, #808085);
    --track: light-dark(#dcdce1, #3d3d41);
    --accent: light-dark(#1d1d1f, #f5f5f7);
    --accent-ink: light-dark(#fff, #1c1c1e);
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.55));
    --day-shadow: light-dark(rgb(0 0 0 / 0.28), rgb(0 0 0 / 0.6));
    --knob-shadow: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.4));
    --knob-edge: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.12));
    --focus: light-dark(#0a84ff, #3594ff);
    --pointer-ring: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.2));
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root button,
.root select {
  font: inherit;
}

/* The list hangs from the top, so a day opening pushes only the days below it. */
.body {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  /* The stage's height; a page that shows it at its content's height sets the variable. */
  min-height: var(--weekly-hours-min-height, 610px);
  padding: 40px 16px 32px;
}
.caption {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--label);
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: 100%;
  max-width: 440px;
  padding: 8px;
  border-radius: 22px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--card-line),
    0 1px 2px var(--shadow-near),
    0 12px 32px -16px var(--shadow);
}

/* A day: grey while off; on, a card of its own, white (a lighter grey at night). */
.day {
  border-radius: 15px;
  background: var(--fill);
  transition:
    background-color 0.3s ease,
    box-shadow 0.35s ease;
}
.day[data-on] {
  background: var(--day);
  box-shadow:
    0 0 0 1px var(--day-line),
    0 8px 22px -14px var(--day-shadow);
}
@media (hover: hover) {
  .day:not([data-on]):hover {
    background: var(--fill-hover);
  }
}

.header {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 46px;
  padding: 0 12px 0 14px;
  cursor: pointer;
  user-select: none;
}
.name {
  font-weight: 500;
  color: var(--text);
  transition: color 0.3s ease;
}
.day[data-on] .name {
  color: var(--ink);
}

/* The switch: a checkbox over its drawing, invisible but there for focus, clicks and screen
   readers. The knob stretches a little while pressed. */
.switchInput {
  position: absolute;
  top: 50%;
  right: 12px;
  width: 42px;
  height: 26px;
  margin: -13px 0 0;
  opacity: 0;
  cursor: pointer;
}
.switch {
  position: relative;
  flex: none;
  width: 42px;
  height: 26px;
  border-radius: 13px;
  background: var(--track);
  pointer-events: none;
  transition: background-color 0.25s ease;
}
.day[data-on] .switch {
  background: var(--accent);
}
.knob {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  border-radius: 10px;
  background: var(--knob-fill);
  box-shadow:
    0 1px 3px var(--knob-shadow),
    0 0 0 0.5px var(--knob-edge);
  transition:
    transform var(--knob-ms) var(--knob),
    width 0.2s var(--ease-out),
    background-color 0.25s ease;
}
.day[data-on] .knob {
  background: var(--accent-ink);
  transform: translateX(16px);
}
.header:active .knob,
.switchInput[data-pressed] + .switch .knob {
  width: 24px;
}
.day[data-on] .header:active .knob,
.day[data-on] .switchInput[data-pressed] + .switch .knob {
  transform: translateX(12px);
}
.switchInput:focus-visible + .switch {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The day's body: a row that grows from nothing to the height of its content. */
.fold {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--fold-ms) var(--fold);
}
.day[data-on] .fold {
  grid-template-rows: 1fr;
}
.foldInner {
  min-height: 0;
  overflow: hidden;
}
.content {
  padding: 0 10px 10px;
  transition:
    opacity 0.3s ease 0.06s,
    transform 0.45s var(--ease-out) 0.06s,
    filter 0.3s ease 0.06s;
}
.day:not([data-on]) .content {
  opacity: 0;
  filter: blur(2px);
  transform: translateY(-6px);
  transition-duration: 0.18s;
  transition-delay: 0s;
}

/* The ranges: each one a row that can open from nothing and fold away. */
.ranges {
  margin: 0;
  padding: 0;
  list-style: none;
}
.range {
  display: grid;
  grid-template-rows: 1fr;
}
.rangeInner {
  min-height: 0;
  overflow: hidden;
}
.rangeRow {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 0;
}
.time {
  display: flex;
  flex: 1;
  align-items: center;
  gap: 7px;
  min-width: 0;
}
.timeLabel {
  font-size: 13px;
  color: var(--ink-muted);
}
.select {
  flex: 1;
  min-width: 0;
  height: 34px;
  margin: 0;
  padding: 0 10px;
  border: 0;
  border-radius: 9px;
  background: var(--field);
  color: var(--ink);
  font-size: 14px;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  appearance: none;
  transition: background-color 0.2s ease;
}
@media (hover: hover) {
  .select:hover {
    background: var(--field-hover);
  }
}
.select:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.remove,
.add {
  margin: 0;
  border: 0;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease,
    transform 0.15s ease;
}
.remove {
  display: grid;
  flex: none;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border-radius: 9px;
  background: none;
  color: var(--faint);
}
.remove svg,
.add svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}
@media (hover: hover) {
  .remove:hover {
    background: var(--field);
    color: var(--ink);
  }
  .add:hover:not(:disabled) {
    background: var(--field-hover);
  }
}
.add {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 100%;
  height: 36px;
  margin-top: 5px;
  padding: 0 12px;
  border-radius: 10px;
  background: var(--field);
  color: var(--text);
  font-size: 14px;
  font-weight: 500;
}
.add:disabled {
  color: var(--faint);
  cursor: default;
}
.remove:is(:active, [data-pressed]),
.add:is(:active, [data-pressed]):not(:disabled) {
  transform: scale(0.96);
}
.remove:focus-visible,
.add:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@container (max-width: 400px) {
  .body {
    padding: 28px 12px 24px;
  }
  .panel {
    padding: 6px;
  }
  .content {
    padding: 0 8px 8px;
  }
  .rangeRow,
  .time {
    gap: 5px;
  }
  .select {
    padding: 0 8px;
    font-size: 13px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .day,
  .name,
  .switch,
  .knob,
  .fold,
  .content,
  .select,
  .remove,
  .add {
    transition: none;
  }
}
```

### weekly-hours/week.ts

```tsx
/*
 * The weekly hours' data: the days, their time ranges in minutes after midnight, and the helpers
 * that keep ranges in order. Times go by half-hours.
 */

export type TimeRange = {
  /** Minutes after midnight: 540 is 9:00 AM. */
  from: number;
  to: number;
};

export type WeekDay = { name: string; on: boolean; ranges: TimeRange[] };

export const WEEK: WeekDay[] = [
  { name: 'Monday', on: true, ranges: [{ from: 9 * 60, to: 12 * 60 + 30 }] },
  { name: 'Tuesday', on: false, ranges: [{ from: 10 * 60, to: 16 * 60 }] },
  { name: 'Wednesday', on: false, ranges: [] },
  { name: 'Thursday', on: false, ranges: [{ from: 8 * 60 + 30, to: 14 * 60 }] },
  { name: 'Friday', on: false, ranges: [] },
];

export const STEP = 30;
const DAY = 24 * 60;

/** Every half-hour of the day; a range starts by 11:00 PM and ends by 11:30 PM. */
const TIMES = Array.from({ length: DAY / STEP }, (_, i) => i * STEP);
export const STARTS = TIMES.slice(0, -1);
export const ENDS = TIMES.slice(1);

/** 540 → "9:00 AM". */
export function clock(minutes: number) {
  const hours = Math.floor(minutes / 60);
  return `${hours % 12 || 12}:${String(minutes % 60).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`;
}

/** A day's first range, when it is switched on with none. */
export const FIRST_RANGE: TimeRange = { from: 9 * 60, to: 17 * 60 };

/** The range after the last one: an hour later, two hours long. Null when the day is full. */
export function nextRange(ranges: TimeRange[]): TimeRange | null {
  if (!ranges.length) return FIRST_RANGE;
  const from = Math.max(...ranges.map((range) => range.to)) + 60;
  if (from > STARTS[STARTS.length - 1]) return null;
  return { from, to: Math.min(from + 120, ENDS[ENDS.length - 1]) };
}

/** Moves one end of a range; the other follows if it has to, so it always ends after it starts. */
export function moveEnd(range: TimeRange, end: keyof TimeRange, minutes: number): TimeRange {
  return end === 'from'
    ? { from: minutes, to: Math.max(range.to, minutes + STEP) }
    : { from: Math.min(range.from, minutes - STEP), to: minutes };
}

/** "9:00 AM to 12:30 PM and 2:00 PM to 6:00 PM". */
export const spoken = (ranges: TimeRange[]) =>
  ranges.map((range) => `${clock(range.from)} to ${clock(range.to)}`).join(' and ');
```

### weekly-hours/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### weekly-hours/demo-cursor.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './demo-cursor.module.css';

/*
 * The demo's cursor. While a demo plays on its own, an arrow glides to the controls and presses
 * them, so the demo reads like someone using the component. Each glide is a CSS transition, one
 * per axis with a different easing, which bends the path into a gentle arc: nothing runs between
 * moves.
 */

export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">
      <span className={styles.vertical}>
        <span className={styles.ripple} />
        <svg className={styles.arrow} viewBox="0 0 20 20">
          <path d="M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z" />
        </svg>
      </span>
    </span>
  );
}

export type DemoSteps = {
  /** Resolves after `ms`; never, once the demo has stopped. */
  wait: (ms: number) => Promise<void>;
  /** Glides the cursor to the middle of `target`, or `offset` from it (in the stage's px). */
  moveTo: (target: Element, offset?: { x: number; y: number }) => Promise<void>;
  /** Presses `target`: the cursor dips, the target shows itself pressed, `action` runs. */
  press: (target: Element, action: () => void) => Promise<void>;
  /** Moves the cursor off and fades it out. */
  leave: () => Promise<void>;
};

type Point = { x: number; y: number };

/**
 * Plays `script` on `stage` with the cursor. The function it returns stops the demo at once: the
 * step in progress never resolves, and the cursor fades away.
 */
export function playDemo(
  stage: HTMLElement,
  cursor: HTMLElement,
  script: (steps: DemoSteps) => Promise<void>,
) {
  let alive = true;
  let timer = 0;
  let pressed: Element | null = null;
  let at: Point = { x: 0, y: 0 };

  const wait = (ms: number) =>
    new Promise<void>((resolve) => {
      timer = window.setTimeout(() => {
        if (alive) resolve();
      }, ms);
    });

  /** A point on the screen, in the stage's own px (it may sit in a scaled container). */
  const toStage = (x: number, y: number): Point => {
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    return { x: (x - box.left) / scale, y: (y - box.top) / scale };
  };

  const place = (point: Point, ms: number) => {
    at = point;
    cursor.style.setProperty('--move', `${ms}ms`);
    cursor.style.setProperty('--x', `${point.x}px`);
    cursor.style.setProperty('--y', `${point.y}px`);
  };

  const moveTo = async (target: Element, offset: Point = { x: 0, y: 0 }) => {
    const box = target.getBoundingClientRect();
    const middle = toStage(box.left + box.width / 2, box.top + box.height / 2);
    const to = { x: middle.x + offset.x, y: middle.y + offset.y };
    if (cursor.dataset.state === 'hidden') {
      // Comes in from below and to the right of its first target, inside the stage.
      place(
        {
          x: Math.min(to.x + 70, stage.offsetWidth - 24),
          y: Math.min(to.y + 80, stage.offsetHeight - 24),
        },
        0,
      );
      // Applies that jump before the glide starts.
      cursor.getBoundingClientRect();
      cursor.dataset.state = 'shown';
    }
    // Longer trips take a little longer.
    const ms = Math.round(Math.min(900, Math.max(420, Math.hypot(to.x - at.x, to.y - at.y) * 2.2)));
    place(to, ms);
    await wait(ms + 40);
  };

  const press = async (target: Element, action: () => void) => {
    cursor.dataset.state = 'pressed';
    target.setAttribute('data-pressed', '');
    pressed = target;
    cursor.querySelector(`.${styles.ripple}`)?.animate(
      [
        { transform: 'scale(0.2)', opacity: 0.5 },
        { transform: 'scale(1)', opacity: 0 },
      ],
      { duration: 520, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
    );
    await wait(130);
    action();
    cursor.dataset.state = 'shown';
    target.removeAttribute('data-pressed');
    pressed = null;
    await wait(120);
  };

  const leave = async () => {
    place({ x: at.x + 36, y: at.y + 44 }, 500);
    cursor.dataset.state = 'hidden';
    await wait(500);
  };

  void script({ wait, moveTo, press, leave });

  return () => {
    alive = false;
    window.clearTimeout(timer);
    cursor.dataset.state = 'hidden';
    pressed?.removeAttribute('data-pressed');
  };
}
```

### weekly-hours/demo-cursor.module.css

```css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in its stage. The
   outer box carries the horizontal move, the inner one the vertical move, eased differently. Its
   colours are the stage's tokens (weekly-hours.module.css). */

.cursor {
  --x: 0px;
  --y: 0px;
  --move: 0ms;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  pointer-events: none;
  opacity: 0;
  transform: translateX(var(--x));
  transition:
    opacity 0.25s ease,
    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.vertical {
  display: block;
  transform: translateY(var(--y));
  transition: transform var(--move) cubic-bezier(0.3, 0.55, 0.25, 1);
}
.arrow {
  display: block;
  width: 22px;
  height: 22px;
  /* The tip, at (4, 2.5) of the drawing, on the point. */
  margin: -2.75px 0 0 -4.4px;
  overflow: visible;
  transform-origin: 4.4px 2.75px;
  transition: transform 0.12s ease;
}
.arrow path {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.cursor[data-state='pressed'] .arrow {
  transform: scale(0.86);
}
/* A soft ring where it presses. */
.ripple {
  position: absolute;
  top: -15px;
  left: -15px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--pointer-ring);
  opacity: 0;
}
```

### weekly-hours/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```