niceui

Availability Scheduler

Set weekly working hours with day toggles and time ranges that animate as they are added or removed.

ReactTailwind CSSTypeScriptBlocksMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/blocks-availability-scheduler.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/blocks/AvailabilityScheduler.tsx
import { useState } from 'react';
import { Check, Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';

export type TimeRange = { id: number; start: string; end: string };
export type DaySchedule = { day: string; enabled: boolean; ranges: TimeRange[] };

const TIMES = Array.from({ length: 33 }, (_, i) => {
  const mins = 6 * 60 + i * 30;
  return `${String(Math.floor(mins / 60)).padStart(2, '0')}:${mins % 60 === 0 ? '00' : '30'}`;
});

let uid = 100;
const range = (start = '09:00', end = '17:00'): TimeRange => ({ id: uid++, start, end });

export const DEFAULT_WEEK: DaySchedule[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day, i) => ({
  day,
  enabled: i < 5,
  ranges: i === 2 ? [range('09:00', '12:00'), range('13:30', '18:00')] : [range()],
}));

function Switch({ on, onChange, label }: { on: boolean; onChange: () => void; label: string }) {
  return (
    <button
      role="switch"
      aria-checked={on}
      aria-label={label}
      onClick={onChange}
      className={cn('focus-ring relative h-5 w-9 shrink-0 rounded-full transition-colors duration-300', on ? 'bg-brand-500' : 'bg-ink-700')}
    >
      <span className={cn('absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform duration-300 ease-spring', on && 'translate-x-4')} />
    </button>
  );
}

function TimeSelect({ value, onChange, label }: { value: string; onChange: (v: string) => void; label: string }) {
  return (
    <select
      aria-label={label}
      value={value}
      onChange={(e) => onChange(e.target.value)}
      className="focus-ring h-8 cursor-pointer appearance-none rounded-lg bg-ink-850 px-2.5 font-mono text-xs text-ink-100 shadow-hairline transition-colors hover:bg-ink-800"
    >
      {TIMES.map((t) => <option key={t} value={t}>{t}</option>)}
    </select>
  );
}

export type AvailabilitySchedulerProps = { value: DaySchedule[]; onChange: (next: DaySchedule[]) => void };

export function AvailabilityScheduler({ value, onChange }: AvailabilitySchedulerProps) {
  const [leaving, setLeaving] = useState<number[]>([]);
  const update = (i: number, patch: Partial<DaySchedule>) => onChange(value.map((d, j) => (j === i ? { ...d, ...patch } : d)));

  const removeRange = (i: number, id: number) => {
    setLeaving((l) => [...l, id]);
    window.setTimeout(() => {
      onChange(value.map((d, j) => (j === i ? { ...d, ranges: d.ranges.filter((r) => r.id !== id), enabled: d.ranges.length > 1 } : d)));
      setLeaving((l) => l.filter((x) => x !== id));
    }, 260);
  };

  return (
    <div className="divide-y divide-white/5">
      {value.map((d, i) => (
        <div key={d.day} className="flex gap-4 py-3">
          <div className="flex w-32 shrink-0 items-center gap-3 self-start pt-1.5">
            <Switch on={d.enabled} label={`Toggle ${d.day}`} onChange={() => update(i, { enabled: !d.enabled, ranges: d.ranges.length ? d.ranges : [range()] })} />
            <span className={cn('text-sm font-medium transition-colors', d.enabled ? 'text-ink-100' : 'text-ink-500')}>{d.day.slice(0, 3)}</span>
          </div>
          <div className="min-w-0 flex-1">
            <div className={cn('grid transition-all duration-500 ease-smooth', d.enabled ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
              <div className="overflow-hidden">
                {d.ranges.map((r, ri) => {
                  const out = leaving.includes(r.id);
                  return (
                    <div key={r.id} className={cn('grid transition-all duration-300 ease-smooth', out ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100')}>
                      <div className="overflow-hidden">
                        <div className={cn('flex items-center gap-2 py-1', !out && 'animate-fade-up')}>
                          <TimeSelect label="Start time" value={r.start} onChange={(v) => update(i, { ranges: d.ranges.map((x) => (x.id === r.id ? { ...x, start: v } : x)) })} />
                          <span className="text-ink-600">–</span>
                          <TimeSelect label="End time" value={r.end} onChange={(v) => update(i, { ranges: d.ranges.map((x) => (x.id === r.id ? { ...x, end: v } : x)) })} />
                          {r.end <= r.start && <span className="animate-fade-in text-[11px] text-rose-400">Invalid</span>}
                          <div className="ml-auto flex gap-0.5">
                            {ri === 0 && (
                              <button aria-label="Add range" onClick={() => update(i, { ranges: [...d.ranges, range('18:00', '20:00')] })} className="focus-ring flex h-8 w-8 items-center justify-center rounded-lg text-ink-400 transition-all hover:bg-white/5 hover:text-ink-100 active:scale-90">
                                <Plus className="h-4 w-4" />
                              </button>
                            )}
                            <button aria-label="Remove range" onClick={() => removeRange(i, r.id)} className="focus-ring flex h-8 w-8 items-center justify-center rounded-lg text-ink-400 transition-all hover:bg-rose-500/10 hover:text-rose-300 active:scale-90">
                              <Trash2 className="h-3.5 w-3.5" />
                            </button>
                          </div>
                        </div>
                      </div>
                    </div>
                  );
                })}
              </div>
            </div>
            {!d.enabled && <p className="animate-fade-in pt-1.5 text-sm text-ink-500">Unavailable</p>}
          </div>
        </div>
      ))}
    </div>
  );
}

export function AvailabilitySchedulerDemo() {
  const [week, setWeek] = useState(DEFAULT_WEEK);
  const [saved, setSaved] = useState(false);
  const hours = week.filter((d) => d.enabled).reduce((sum, d) => sum + d.ranges.reduce((s, r) => {
    const [sh, sm] = r.start.split(':').map(Number); const [eh, em] = r.end.split(':').map(Number);
    return s + Math.max(0, eh * 60 + em - sh * 60 - sm) / 60;
  }, 0), 0);

  return (
    <div className="mx-auto w-full max-w-lg rounded-2xl bg-ink-900 p-5 shadow-card">
      <div className="mb-2 flex items-center justify-between">
        <div>
          <p className="text-sm font-medium text-ink-50">Working hours</p>
          <p className="text-xs text-ink-400">{hours.toFixed(1)}h available per week · Europe/London</p>
        </div>
        <button
          onClick={() => { setSaved(true); window.setTimeout(() => setSaved(false), 1800); }}
          className={cn('focus-ring flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all active:scale-95', saved ? 'bg-emerald-500/15 text-emerald-300' : 'bg-ink-50 text-ink-950 hover:bg-white')}
        >
          {saved && <Check className="h-3.5 w-3.5 animate-scale-in" />} {saved ? 'Saved' : 'Save changes'}
        </button>
      </div>
      <AvailabilityScheduler value={week} onChange={setWeek} />
    </div>
  );
}