World Clocks
A list of city clocks with day/night markers and offsets from you; add a city from search and remove with a tap.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/world-clocks.json'use client'
import { useEffect, useId, useState } from 'react'
import './world-clocks.css'
export interface Zone { city: string; tz: string }
const CITIES: Zone[] = [
{ city: 'San Francisco', tz: 'America/Los_Angeles' }, { city: 'New York', tz: 'America/New_York' }, { city: 'São Paulo', tz: 'America/Sao_Paulo' }, { city: 'London', tz: 'Europe/London' },
{ city: 'Lisbon', tz: 'Europe/Lisbon' }, { city: 'Berlin', tz: 'Europe/Berlin' }, { city: 'Nairobi', tz: 'Africa/Nairobi' }, { city: 'Dubai', tz: 'Asia/Dubai' }, { city: 'Mumbai', tz: 'Asia/Kolkata' },
{ city: 'Singapore', tz: 'Asia/Singapore' }, { city: 'Tokyo', tz: 'Asia/Tokyo' }, { city: 'Sydney', tz: 'Australia/Sydney' }, { city: 'Auckland', tz: 'Pacific/Auckland' },
]
export interface WorldClocksProps {
zones: Zone[]
hour12?: boolean
workHours?: boolean
fill?: 'dark' | 'light'
className?: string
}
const parts = (d: Date, tz: string) => { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' }).formatToParts(d).map((x) => [x.type, x.value])); return { y: +p.year, mo: +p.month, d: +p.day, h: +p.hour % 24, m: +p.minute } }
/** City clocks with offsets, day/night and add/remove. */
export function WorldClocks({ zones: z0, hour12 = true, workHours = true, fill = 'dark', className }: WorldClocksProps) {
const [zones, setZones] = useState(z0)
const [now, setNow] = useState<Date | null>(null)
const [q, setQ] = useState('')
const [act, setAct] = useState(0)
const id = useId()
useEffect(() => { setNow(new Date()); const i = setInterval(() => setNow(new Date()), 20000); return () => clearInterval(i) }, [])
const opts = q.trim() ? CITIES.filter((c) => c.city.toLowerCase().includes(q.trim().toLowerCase()) && !zones.some((z) => z.tz === c.tz)).slice(0, 5) : []
const add = (z: Zone) => { setZones((zs) => [...zs, z]); setQ(''); setAct(0) }
const here = now ? parts(now, Intl.DateTimeFormat().resolvedOptions().timeZone) : null
const rows = now && here ? zones.map((z) => {
const p = parts(now, z.tz)
const mins = (Date.UTC(p.y, p.mo - 1, p.d, p.h, p.m) - Date.UTC(here.y, here.mo - 1, here.d, here.h, here.m)) / 60000
const dd = Date.UTC(p.y, p.mo - 1, p.d) - Date.UTC(here.y, here.mo - 1, here.d)
return { z, p, off: Math.round(mins / 30) / 2, day: dd > 0 ? 'Tomorrow' : dd < 0 ? 'Yesterday' : 'Today' }
}).sort((a, b) => a.off - b.off) : []
const fmt = (h: number, m: number) => hour12 ? <>{h % 12 || 12}:{String(m).padStart(2, '0')}<small>{h < 12 ? 'am' : 'pm'}</small></> : <>{String(h).padStart(2, '0')}:{String(m).padStart(2, '0')}</>
return (
<div className={['sd-wcl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-wcl__add">
<input role="combobox" aria-expanded={opts.length > 0} aria-controls={`${id}-l`} aria-activedescendant={opts.length ? `${id}-${act}` : undefined} aria-label="Add a city" placeholder="Add a city"
value={q} onChange={(e) => { setQ(e.target.value); setAct(0) }}
onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setAct((a) => Math.min(opts.length - 1, a + 1)) } if (e.key === 'ArrowUp') { e.preventDefault(); setAct((a) => Math.max(0, a - 1)) } if (e.key === 'Enter' && opts[act]) add(opts[act]); if (e.key === 'Escape') setQ('') }} />
{opts.length > 0 && (
<ul id={`${id}-l`} role="listbox" className="sd-wcl__opts">
{opts.map((o, i) => <li key={o.tz} id={`${id}-${i}`} role="option" aria-selected={i === act} onPointerDown={(e) => { e.preventDefault(); add(o) }}>{o.city}<span>{now ? fmt(parts(now, o.tz).h, parts(now, o.tz).m) : ''}</span></li>)}
</ul>
)}
</div>
<ul className="sd-wcl__list">
{rows.map(({ z, p, off, day }) => {
const night = p.h < 6 || p.h >= 20, work = p.h >= 9 && p.h < 18
return (
<li key={z.tz} className="sd-wcl__r" aria-label={`${z.city}, ${p.h % 12 || 12}:${String(p.m).padStart(2, '0')} ${p.h < 12 ? 'am' : 'pm'}, ${off === 0 ? 'same time as you' : `${Math.abs(off)} hours ${off > 0 ? 'ahead' : 'behind'}`}, ${day.toLowerCase()}`}>
<svg className="sd-wcl__dn" viewBox="0 0 18 18" data-night={night || undefined} aria-hidden>{night ? <path d="M13 11.5A5.5 5.5 0 0 1 6.5 5a5.5 5.5 0 1 0 6.5 6.5z" /> : <><circle cx="9" cy="9" r="3.2" /><path d="M9 1.8v1.6M9 14.6v1.6M1.8 9h1.6M14.6 9h1.6M3.9 3.9 5 5M13 13l1.1 1.1M3.9 14.1 5 13M13 5l1.1-1.1" /></>}</svg>
<span className="sd-wcl__c">{z.city}{workHours && work && <em>Working</em>}<small>{day}, {off === 0 ? 'your time' : `${off > 0 ? '+' : '−'}${Math.abs(off)}h`}</small></span>
<span className="sd-wcl__t" aria-hidden>{fmt(p.h, p.m)}</span>
<button type="button" className="sd-wcl__x" aria-label={`Remove ${z.city}`} onClick={() => setZones((zs) => zs.filter((x) => x.tz !== z.tz))}><svg viewBox="0 0 12 12" aria-hidden><path d="m3 3 6 6M9 3 3 9" /></svg></button>
</li>
)
})}
</ul>
</div>
)
}.sd-wcl { --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-wcl[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-wcl :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-wcl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-wcl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-wcl { width: min(100%, 330px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-wcl__add { position: relative; padding: 12px; border-bottom: 1px solid var(--line); }
.sd-wcl__add input { width: 100%; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13.5px; outline: 0; }
.sd-wcl__add input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-wcl__add input::placeholder { color: var(--mute); }
.sd-wcl__opts { position: absolute; z-index: 2; left: 12px; right: 12px; top: 52px; margin: 0; padding: 4px; list-style: none; border-radius: 12px; background: var(--bg); box-shadow: 0 14px 30px -10px rgb(0 0 0 / 0.55), inset 0 0 0 1px var(--line); }
.sd-wcl__opts li { display: flex; justify-content: space-between; padding: 8px 10px; border-radius: 8px; font-size: 13.5px; cursor: pointer; }
.sd-wcl__opts li[aria-selected='true'] { background: var(--soft); }
.sd-wcl__opts li span { color: var(--mute); font-size: 12px; }
.sd-wcl__list { list-style: none; margin: 0; padding: 4px 0; max-height: 330px; overflow-y: auto; }
.sd-wcl__r { display: grid; grid-template-columns: 22px 1fr auto 26px; align-items: center; gap: 10px; padding: 10px 10px 10px 14px; animation: sd-wcl-in 240ms ease-out; }
.sd-wcl__r + .sd-wcl__r { border-top: 1px solid var(--line); }
.sd-wcl__dn { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
.sd-wcl__dn[data-night] { fill: currentColor; stroke: none; opacity: 0.7; }
.sd-wcl__c { min-width: 0; font-size: 14px; font-weight: 500; }
.sd-wcl__c small { display: block; font-size: 12px; font-weight: 400; color: var(--mute); }
.sd-wcl__c em { font-style: normal; margin-left: 6px; padding: 1px 6px; border-radius: 99px; font-size: 10.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); vertical-align: 1px; }
.sd-wcl__t { font-size: 22px; font-weight: 300; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.sd-wcl__t small { font-size: 12px; margin-left: 2px; color: var(--mute); }
.sd-wcl__x { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; color: var(--mute) !important; }
.sd-wcl__x:hover { background: var(--soft); color: var(--fg) !important; }
.sd-wcl__x svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
@keyframes sd-wcl-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-wcl *, .sd-wcl *::before, .sd-wcl *::after { animation: none !important; transition: none !important; } }Overview
Each row shows the city’s time, its offset from your clock (“+5h, tomorrow”) and whether it is working hours there.
Add a city through search; rows reorder by offset. Times tick every minute.
When to use
- Remote team tools, scheduling, travel apps.
When not to use
- Picking a meeting time: use a time-zone slider.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/world-clocks.json$
npx shadcn@latest add https://sundaeui.dev/r/world-clocks.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/world-clocks.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/world-clocks.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 world-clocks.tsx and world-clocks.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 { WorldClocks } from '@/components/sundae/world-clocks'
<WorldClocks />Props
| Prop | Type | Default |
|---|---|---|
zones*Cities. | { city: string; tz: string }[] | — |
hour1212-hour clock. | boolean | true |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each row reads “Tokyo, 11:42 pm, 9 hours ahead, tomorrow”.
- Search is a combobox with a listbox of cities.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-wcl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is World Clocks free?
- Yes. World Clocks is free for personal and commercial projects, with full source on this page.
- How do I install World Clocks?
- Run npx shadcn@latest add https://sundaeui.dev/r/world-clocks.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does World Clocks support keyboard and screen readers?
- Each row reads “Tokyo, 11:42 pm, 9 hours ahead, tomorrow”. Search is a combobox with a listbox of cities.
- Does World Clocks respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Alarm List
Components · Refined- Pro
Swipe Actions
Components · Refined Snooze Menu
Components · Refined- Pro
Sortable List
Components · Refined Timezone Slider
Components · Refined- Pro
Bulk Actions Bar
Components · Refined