├── 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 | --------------------------------------------------------------------------------