Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

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.

weekly-hours/weekly-hours.tsx
Live · interactive
Availability

      The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

      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.

      You can add this component with the shadcn CLI or by hand:

      Into components/weekly-hours of any React project, with no setup:

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

      Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

      availability.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)} />;}

      Each state below is a live instance, locked so you can study it.

      The pieces it is built from, each with its measurements read live.

      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 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.
      PropTypeDefaultDescription
      weekWeekDay[]WEEKThe days in order: a name, whether it is on, and its ranges as { from, to } in minutes after midnight.
      labelstring'Availability'The list’s caption and accessible name.
      onChange(week: WeekDay[]) => void—Called with the whole week after every change.
      autoplaybooleantrueWhile 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.
      classNamestring—Extra class for the root, a stage of its own.

      The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

      weekly-hours.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,
      weekly-hours.module.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
      week.ts
      /* * 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;
      spring.ts
      /* * 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;
      demo-cursor.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>  );}
      demo-cursor.module.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 {
      use-autoplay.ts
      '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;
      • 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.