├── src
├── vite-env.d.ts
├── utils
│ ├── cn.ts
│ └── time.ts
├── index.css
├── main.tsx
├── App.tsx
└── components
│ ├── TimeSlotInput.tsx
│ ├── Schedule.tsx
│ └── DaySchedule.tsx
├── postcss.config.js
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── .gitignore
├── index.html
├── tsconfig.node.json
├── tsconfig.app.json
├── eslint.config.js
├── package.json
├── public
└── vite.svg
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(...inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | * {
6 | margin: 0;
7 | padding: 0;
8 | box-sizing: border-box;
9 | }
10 |
11 | body {
12 | font-family: "Poppins", sans-serif;
13 | }
14 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Schedule } from "./components/Schedule";
2 |
3 | export default function App() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Time Slot Picker
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "erasableSyntaxOnly": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true
23 | },
24 | "include": ["vite.config.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "erasableSyntaxOnly": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "slot-picker-interaction",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "clsx": "^2.1.1",
14 | "lucide-react": "^0.516.0",
15 | "motion": "^12.18.1",
16 | "react": "^19.1.0",
17 | "react-dom": "^19.1.0",
18 | "tailwind-merge": "^3.3.1"
19 | },
20 | "devDependencies": {
21 | "@eslint/js": "^9.25.0",
22 | "@types/react": "^19.1.2",
23 | "@types/react-dom": "^19.1.2",
24 | "@vitejs/plugin-react": "^4.4.1",
25 | "autoprefixer": "^10.4.21",
26 | "eslint": "^9.25.0",
27 | "eslint-plugin-react-hooks": "^5.2.0",
28 | "eslint-plugin-react-refresh": "^0.4.19",
29 | "globals": "^16.0.0",
30 | "postcss": "^8.5.6",
31 | "tailwindcss": "^3.4.17",
32 | "typescript": "~5.8.3",
33 | "typescript-eslint": "^8.30.1",
34 | "vite": "^6.3.5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | export function generateRandomTime() {
2 | const hour = Math.floor(Math.random() * 12) || 12;
3 | const minute = Math.floor(Math.random() * 60)
4 | .toString()
5 | .padStart(2, "0");
6 | const period = Math.random() > 0.5 ? "AM" : "PM";
7 | return `${hour}:${minute} ${period}`;
8 | }
9 |
10 | // Formats loose user input into a standard time string like '07:00 AM'.
11 | export function parseTimeInput(input: string): string {
12 | let str = input.trim().toLowerCase().replace(/\s+/g, "");
13 | let hour = 0;
14 | let minute = 0;
15 | let isPM = false;
16 | let isAM = false;
17 |
18 | if (str.endsWith("am")) {
19 | isAM = true;
20 | str = str.replace("am", "");
21 | } else if (str.endsWith("pm")) {
22 | isPM = true;
23 | str = str.replace("pm", "");
24 | }
25 |
26 | if (str.includes(":")) {
27 | const [h, m] = str.split(":");
28 | hour = parseInt(h, 10);
29 | minute = parseInt(m, 10) || 0;
30 | } else if (str.length >= 3 && str.length <= 4) {
31 | const padded = str.padStart(4, "0");
32 | hour = parseInt(padded.slice(0, 2), 10);
33 | minute = parseInt(padded.slice(2, 4), 10);
34 | } else if (str.length > 0) {
35 | hour = parseInt(str, 10);
36 | minute = 0;
37 | }
38 |
39 | hour = Math.max(0, Math.min(hour, 23));
40 | minute = Math.max(0, Math.min(minute, 59));
41 |
42 | if (isAM || isPM) {
43 | if (hour === 0) hour = 12;
44 | if (hour > 12) hour = hour % 12;
45 | if (isPM && hour !== 12) hour += 12;
46 | }
47 |
48 | let period = "AM";
49 | let displayHour = hour;
50 | if (!isAM && !isPM) {
51 | if (hour === 0) {
52 | displayHour = 12;
53 | period = "AM";
54 | } else if (hour === 12) {
55 | displayHour = 12;
56 | period = "PM";
57 | } else if (hour > 12) {
58 | displayHour = hour - 12;
59 | period = "PM";
60 | } else {
61 | displayHour = hour;
62 | period = "AM";
63 | }
64 | } else {
65 | if (hour === 0 || hour === 12) {
66 | displayHour = 12;
67 | } else if (hour > 12) {
68 | displayHour = hour - 12;
69 | } else {
70 | displayHour = hour;
71 | }
72 | period = isPM ? "PM" : "AM";
73 | }
74 |
75 | return `${displayHour.toString().padStart(2, "0")}:${minute
76 | .toString()
77 | .padStart(2, "0")} ${period}`;
78 | }
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🕒 Time Slot Picker
2 |
3 | A project I built out of frustration with most time pickers out there. I wanted something that actually lets you express real-world availability. Not just "anytime" or a single range, but as many precise slots as you need. Think: 7:30–9:00, then again 13:00–15:30, and so on. All with a clean, minimal, and accessible UI that feels great to use.
4 |
5 | ## ✨ Tech Stack
6 |
7 | - `React`
8 | - `TypeScript`
9 | - `Framer Motion`
10 | - `Tailwind CSS`
11 | - `Vite`
12 | - `clsx` + `tailwind-merge` for className composition
13 |
14 | ## 🚀 Features
15 |
16 | - **Real Time Ranges:** Add as many slots as you want for each day. No more "anytime" nonsense.
17 | - **Instant Input Focus:** Open a day's schedule and the first "From" input is ready to go. No extra clicks.
18 | - **Accessibility First:**
19 | - The toggle is a real switch (`role="switch"`, `aria-checked`, `aria-label`), not just a styled button.
20 | - Every button and control is keyboard accessible.
21 | - No annoying focus rings, but still easy to navigate.
22 | - **Apple-Inspired Polish:**
23 | - Soft, rounded corners, gentle shadows, and subtle transitions.
24 | - Outlined, pill-shaped inputs and buttons.
25 | - **Minimal, Responsive Design:**
26 | - Looks great on desktop, adapts well to smaller screens.
27 | - **No Distractions:**
28 | - No colored focus rings or weird chrome—just a subtle border change on focus.
29 | - Remove buttons are perfectly aligned for that satisfying visual harmony.
30 | - **Clean Class Logic:**
31 | - I use a custom `cn` utility (combining `clsx` and `tailwind-merge`) to keep className logic tidy.
32 |
33 | ## 💭 Why I Made This
34 |
35 | I kept running into time pickers that were either too basic or way too clunky. I wanted something that just works for real-life schedules—like when you're only available in the morning and then again in the evening. I obsessed over the details:
36 |
37 | - The first input is always focused so you can just start typing.
38 | - Every control is labeled and accessible.
39 | - The UI is so clean, people say "wow" when they see it.
40 | - No distractions, just clarity.
41 |
42 | ## 🚦 Running the Project
43 |
44 | 1. Clone the repository
45 | 2. Install dependencies: `npm install`
46 | 3. Run development server: `npm run dev`
47 | 4. Open `http://localhost:5173` in your browser
48 |
49 | ## 🎞️ Preview
50 |
51 |
52 | https://github.com/user-attachments/assets/fd274f21-143e-43b4-9049-a1cb365151cd
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/components/TimeSlotInput.tsx:
--------------------------------------------------------------------------------
1 | import { X } from "lucide-react";
2 | import React, { useState } from "react";
3 | import { parseTimeInput } from "../utils/time";
4 | import { cn } from "../utils/cn";
5 |
6 | interface TimeSlotInputProps {
7 | from: string;
8 | to: string;
9 | onChange: (field: "from" | "to", value: string) => void;
10 | onRemove: () => void;
11 | autoFocus?: boolean;
12 | }
13 |
14 | export const TimeSlotInput: React.FC = ({
15 | from,
16 | to,
17 | onChange,
18 | onRemove,
19 | autoFocus,
20 | }) => {
21 | const [fromValue, setFromValue] = useState(from);
22 | const [toValue, setToValue] = useState(to);
23 |
24 | React.useEffect(() => {
25 | setFromValue(from);
26 | }, [from]);
27 | React.useEffect(() => {
28 | setToValue(to);
29 | }, [to]);
30 |
31 | const handleBlur = (field: "from" | "to", value: string) => {
32 | const formatted = parseTimeInput(value);
33 | if (field === "from") setFromValue(formatted);
34 | else setToValue(formatted);
35 | onChange(field, formatted);
36 | };
37 |
38 | return (
39 |
44 |
45 | From
46 | setFromValue(e.target.value)}
50 | onBlur={(e) => handleBlur("from", e.target.value)}
51 | className={cn(
52 | "px-4 py-2 bg-white w-full rounded-lg border text-sm shadow-sm border-zinc-200 focus:border-zinc-400 focus:outline-none h-10 transition-border"
53 | )}
54 | autoFocus={!!autoFocus}
55 | />
56 |
57 |
58 | To
59 | setToValue(e.target.value)}
63 | onBlur={(e) => handleBlur("to", e.target.value)}
64 | className={cn(
65 | "px-4 py-2 bg-white w-full rounded-lg border text-sm shadow-sm border-zinc-200 focus:border-zinc-400 focus:outline-none h-10 transition-border"
66 | )}
67 | />
68 |
69 |
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/components/Schedule.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { DaySchedule } from "./DaySchedule";
3 | import type { TimeSlot } from "./DaySchedule";
4 | import { generateRandomTime } from "../utils/time";
5 |
6 | export type WeekDay =
7 | | "Monday"
8 | | "Tuesday"
9 | | "Wednesday"
10 | | "Thursday"
11 | | "Friday";
12 |
13 | interface DayScheduleState {
14 | enabled: boolean;
15 | timeSlots: TimeSlot[];
16 | }
17 |
18 | type WeekSchedule = Record;
19 |
20 | export const Schedule: React.FC = () => {
21 | const [schedule, setSchedule] = useState({
22 | Monday: { enabled: false, timeSlots: [] },
23 | Tuesday: { enabled: false, timeSlots: [] },
24 | Wednesday: { enabled: false, timeSlots: [] },
25 | Thursday: { enabled: false, timeSlots: [] },
26 | Friday: { enabled: false, timeSlots: [] },
27 | });
28 |
29 | const handleToggleDay = (day: WeekDay) => {
30 | setSchedule((prev) => ({
31 | ...prev,
32 | [day]: { ...prev[day], enabled: !prev[day].enabled },
33 | }));
34 | };
35 |
36 | const handleAddSlot = (day: WeekDay) => {
37 | setSchedule((prev) => ({
38 | ...prev,
39 | [day]: {
40 | ...prev[day],
41 | timeSlots: [
42 | ...prev[day].timeSlots,
43 | {
44 | id: Date.now().toString() + Math.random().toString(36).slice(2),
45 | from: generateRandomTime(),
46 | to: generateRandomTime(),
47 | },
48 | ],
49 | },
50 | }));
51 | };
52 |
53 | const handleRemoveSlot = (day: WeekDay, id: string) => {
54 | setSchedule((prev) => ({
55 | ...prev,
56 | [day]: {
57 | ...prev[day],
58 | timeSlots: prev[day].timeSlots.filter((slot) => slot.id !== id),
59 | },
60 | }));
61 | };
62 |
63 | const handleChangeSlot = (
64 | day: WeekDay,
65 | id: string,
66 | field: "from" | "to",
67 | value: string
68 | ) => {
69 | setSchedule((prev) => ({
70 | ...prev,
71 | [day]: {
72 | ...prev[day],
73 | timeSlots: prev[day].timeSlots.map((slot) =>
74 | slot.id === id ? { ...slot, [field]: value } : slot
75 | ),
76 | },
77 | }));
78 | };
79 |
80 | return (
81 |
82 | {Object.entries(schedule).map(([day, { enabled, timeSlots }]) => (
83 | handleToggleDay(day as WeekDay)}
89 | onAddSlot={() => handleAddSlot(day as WeekDay)}
90 | onRemoveSlot={(id) => handleRemoveSlot(day as WeekDay, id)}
91 | onChangeSlot={(id, field, value) =>
92 | handleChangeSlot(day as WeekDay, id, field, value)
93 | }
94 | />
95 | ))}
96 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/src/components/DaySchedule.tsx:
--------------------------------------------------------------------------------
1 | import { motion, AnimatePresence } from "motion/react";
2 | import { TimeSlotInput } from "./TimeSlotInput";
3 | import React from "react";
4 | import { cn } from "../utils/cn";
5 |
6 | export interface TimeSlot {
7 | id: string;
8 | from: string;
9 | to: string;
10 | }
11 |
12 | interface DayScheduleProps {
13 | day: string;
14 | enabled: boolean;
15 | timeSlots: TimeSlot[];
16 | onToggle: () => void;
17 | onAddSlot: () => void;
18 | onRemoveSlot: (id: string) => void;
19 | onChangeSlot: (id: string, field: "from" | "to", value: string) => void;
20 | }
21 |
22 | export const DaySchedule: React.FC = ({
23 | day,
24 | enabled,
25 | timeSlots,
26 | onToggle,
27 | onAddSlot,
28 | onRemoveSlot,
29 | onChangeSlot,
30 | }) => (
31 |
43 |
44 | {day}
45 |
57 |
62 |
63 |
64 |
65 | {enabled && (
66 |
67 | {timeSlots.map((slot, idx) => (
68 | onChangeSlot(slot.id, field, value)}
73 | onRemove={() => onRemoveSlot(slot.id)}
74 | autoFocus={idx === 0}
75 | />
76 | ))}
77 |
90 | Add More
91 |
92 |
93 | )}
94 |
95 |
96 | );
97 |
--------------------------------------------------------------------------------