Timezone Slider
Drag one time across a day and see it in every team member’s timezone, with working hours shaded on each track.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/timezone-slider.json'use client'
import { useRef, useState } from 'react'
import './timezone-slider.css'
export interface TzZone { label: string; tz: string; start?: number; end?: number }
export interface TimezoneSliderProps {
zones: TzZone[]
onChange?: (utc: Date) => void
hour12?: boolean
fill?: 'dark' | 'light'
className?: string
}
const hourIn = (d: Date, tz: string) => {
const p = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: 'numeric', hourCycle: 'h23' }).formatToParts(d)
return Number(p.find((x) => x.type === 'hour')?.value ?? 0) + Number(p.find((x) => x.type === 'minute')?.value ?? 0) / 60
}
/** Drag one instant across several timezones. */
export function TimezoneSlider({ zones, onChange, hour12 = true, fill = 'dark', className }: TimezoneSliderProps) {
const [base] = useState(() => { const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d.getTime() })
const [mins, setMins] = useState(() => 15 * 60)
const area = useRef<HTMLDivElement>(null)
const drag = useRef(false)
const at = new Date(base + mins * 60000)
const set = (m: number) => { const c = ((Math.round(m / 15) * 15) % 1440 + 1440) % 1440; setMins(c); onChange?.(new Date(base + c * 60000)) }
const fromX = (x: number) => { const r = area.current!.getBoundingClientRect(); set(((x - r.left) / r.width) * 1440) }
const loc = zones.map((z) => {
const h = hourIn(at, z.tz)
const s = z.start ?? 9, e = z.end ?? 18
return { z, h, on: h >= s && h < e, txt: at.toLocaleTimeString('en-US', { timeZone: z.tz, hour: 'numeric', minute: '2-digit', hour12 }), day: at.toLocaleDateString('en-US', { timeZone: z.tz, weekday: 'short' }) }
})
const pct = (mins / 1440) * 100
const all = loc.every((l) => l.on)
return (
<div className={['sd-tzs', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-tzs__h"><span>Drag to find a time</span><b>{at.toLocaleTimeString('en-US', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false })} UTC</b></div>
<div
ref={area}
className="sd-tzs__area"
role="slider"
tabIndex={0}
aria-label="Meeting time"
aria-valuemin={0}
aria-valuemax={1439}
aria-valuenow={mins}
aria-valuetext={loc.map((l) => `${l.z.label} ${l.txt}`).join(', ')}
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = true; fromX(e.clientX) }}
onPointerMove={(e) => drag.current && fromX(e.clientX)}
onPointerUp={() => { drag.current = false }}
onKeyDown={(e) => {
const m: Record<string, number> = { ArrowRight: 15, ArrowLeft: -15, PageUp: 60, PageDown: -60 }
if (e.key in m) { e.preventDefault(); set(mins + m[e.key]) }
}}
>
{loc.map(({ z, on, txt, day }) => {
const off = (hourIn(new Date(base), z.tz) + 24) % 24
const s = (((z.start ?? 9) - off + 24) % 24) / 24 * 100
const w = (((z.end ?? 18) - (z.start ?? 9)) / 24) * 100
return (
<div key={z.tz} className="sd-tzs__row" data-off={!on || undefined}>
<span className="sd-tzs__city">{z.label}</span>
<span className="sd-tzs__time">{txt}<small>{day}</small></span>
<div className="sd-tzs__track">
<span className="sd-tzs__work" style={{ left: `${s}%`, width: `${w}%` }} />
{s + w > 100 && <span className="sd-tzs__work" style={{ left: 0, width: `${s + w - 100}%` }} />}
<span className="sd-tzs__cursor" style={{ left: `${pct}%` }} />
</div>
</div>
)
})}
</div>
<div className="sd-tzs__foot" aria-live="polite">{all ? 'Everyone is within working hours' : `${loc.filter((l) => !l.on).length} outside working hours`}</div>
</div>
)
}.sd-tzs { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-tzs[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-tzs button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-tzs :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-tzs .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-tzs { width: min(100%, 340px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-tzs__h { display: flex; justify-content: space-between; font-size: 12.5px; color: var(--mute); margin-bottom: 10px; }
.sd-tzs__h b { color: var(--fg); }
.sd-tzs__area { position: relative; touch-action: none; cursor: ew-resize; outline: 0; border-radius: 10px; }
.sd-tzs__area:focus-visible { box-shadow: 0 0 0 2px var(--fg); }
.sd-tzs__row { display: grid; grid-template-columns: 1fr auto; gap: 4px 8px; padding: 6px 0; transition: opacity 200ms; }
.sd-tzs__row[data-off] { opacity: 0.45; }
.sd-tzs__city { font-size: 13px; font-weight: 600; }
.sd-tzs__time { font-size: 13px; font-weight: 600; text-align: right; }
.sd-tzs__time small { font-weight: 400; color: var(--mute); margin-left: 4px; }
.sd-tzs__track { grid-column: 1 / -1; position: relative; height: 10px; border-radius: 4px; background: var(--soft); overflow: hidden; }
.sd-tzs__work { position: absolute; top: 0; bottom: 0; background: color-mix(in srgb, var(--fg) 28%, transparent); }
.sd-tzs__cursor { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: var(--fg); pointer-events: none; }
.sd-tzs__cursor::before { content: ''; position: absolute; top: -4px; left: -4px; width: 10px; height: 10px; border-radius: 99px; background: var(--fg); }
.sd-tzs__foot { margin-top: 10px; font-size: 12px; color: var(--mute); text-align: center; }
@media (prefers-reduced-motion: reduce) { .sd-tzs *, .sd-tzs *::before, .sd-tzs *::after { animation: none !important; transition: none !important; } }Overview
Each row is a 24-hour track for one city with its working hours marked. A single vertical cursor sweeps all rows at once, and every row shows its local time at that moment.
Rows outside working hours dim, so finding a slot that suits everyone is a matter of sliding until all rows are lit.
When to use
- Scheduling across distributed teams, event times for global audiences.
When not to use
- A single-timezone product.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/timezone-slider.json$
npx shadcn@latest add https://sundaeui.dev/r/timezone-slider.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/timezone-slider.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/timezone-slider.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy timezone-slider.tsx and timezone-slider.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TimezoneSlider } from '@/components/sundae/timezone-slider'
<TimezoneSlider />Props
| Prop | Type | Default |
|---|---|---|
zones*IANA zones and working hours (24h). | { label: string; tz: string; start?: number; end?: number }[] | — |
onChangeCalled with the chosen instant. | (utc: Date) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A slider with aria-valuetext listing every local time.
- Arrow keys step 15 minutes; Page Up/Down step an hour.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-tzs; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- ±15 minutes
- Page Up / Down
- ±1 hour
FAQ
- Is Timezone Slider free?
- Yes. Timezone Slider is free for personal and commercial projects, with full source on this page.
- How do I install Timezone Slider?
- Run npx shadcn@latest add https://sundaeui.dev/r/timezone-slider.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Timezone Slider support keyboard and screen readers?
- A slider with aria-valuetext listing every local time. Arrow keys step 15 minutes; Page Up/Down step an hour.
- Does Timezone Slider respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Snap Carousel
Components · RefinedNext Meeting Card
Components · RefinedWorld Clocks
Components · RefinedCall Grid
Components · Refined- Pro
Glass Control Center
Components · Refined Toast Stack
Components · Refined