Timezone globe
AdvancedA time zone picker beside a halftone globe: hover, search or arrow through the zones and the globe turns to light up each one’s slice of the Earth.
timezone-globe/timezone-globe.tsxSelect your timezone
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Setting the time zone of an account, a calendar or a team member, where seeing the zone on the map settles the doubt between two similar names.
- People who know a city but not its zone’s name: the search finds
Tokyo,Brazil,+5:30orUTC-6. - Scheduling across places, where each row’s live local time answers “what time is it there?” before choosing.
- A deliberate, occasional choice that deserves a dialog with Cancel and an explicit
Selectbutton.
When not to use
- A field in a long form where the zone can be guessed. Preselect the reader’s zone and offer a plain select to change it.
- Choosing among every IANA zone (over 400). This lists about 30 broad zones, each with its main cities; use a searchable combobox for the full list.
- A narrow sidebar or a popover: the dialog needs about 560 px for the globe beside the list (it stacks below that, at about 600 px tall).
- Showing several zones at once, as in a meeting planner. Use a timeline of the zones side by side.
- Changing a zone often, as a filter. A compact menu is faster than a dialog that has to be confirmed.
You can add this component with the shadcn CLI or by hand:
Into components/timezone-globe of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/timezone-globe.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
timezone-globe/timezone-globe.tsxtimezone-globe/timezone-globe.module.csstimezone-globe/globe.tstimezone-globe/sphere.tstimezone-globe/land.tstimezone-globe/globe-still.tsxtimezone-globe/zones.tstimezone-globe/clock.tstimezone-globe/search.tstimezone-globe/list-scroll.tstimezone-globe/ghost-pointer.tsxtimezone-globe/use-autoplay.ts
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { TimezoneGlobe } from './timezone-globe';// Opens on the account's zone; the reader confirms another one, or cancels.export function TimezoneDialog({ onClose }: { onClose: (timeZone: string | null) => void }) { return ( <TimezoneGlobe autoplay={false} defaultZone="central" onSelect={(zone) => onClose(zone.timeZone)} onCancel={() => onClose(null)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A dialog for choosing a time zone: a searchable list of 27 zones with their live local times, beside a globe drawn as a halftone of dots. Point at a zone, find a city or arrow down the list, and the globe turns on a spring until that zone’s band of longitude faces you, its main cities marked. Drag the globe to spin it, or click it to pick the zone under the pointer.
- Every time is live. Each zone’s clock is read with Intl.DateTimeFormat from its IANA name, once a minute, with the abbreviation the browser knows (MDT) or the zone’s own where it only gives an offset. The reader’s zone, from resolvedOptions().timeZone, is matched to the nearest listed zone by offset and tagged.
- The land is a halftone. 12,000 points are spread evenly over the sphere on a Fibonacci spiral and kept where they fall inside coastlines written by hand in longitude and latitude: about 3,500 dots, worked out once, the same on the server and in the browser.
- The globe is a 2D canvas in orthographic projection. A zone’s band is the lune between two meridians 15° apart, centred on its offset × 15° and clipped to the near side. A spring that keeps its speed when the target changes turns the band into view; a drag spins the globe with inertia, and a click picks the zone under the pointer by longitude.
- The server draws the same view as an SVG still, its dots drawn as zero-length strokes with round caps, so the first frame is complete; the canvas takes over once it has drawn. It draws only while something moves, caps its pixel ratio at 2 and gives its pixels back off screen.
- In Safari a scroll container gets a GPU layer, and in a scaled-down thumbnail full-size layers form around it. So the list is an overflow: hidden box that the wheel, a finger (with a flick that slows down), the keyboard and the demo scroll by script, which keeps an iPhone under 75 MB where a native scrolling list took a thumbnail to 234 MB.
- Every colour is a light-dark() token on the root, so the dialog follows the page’s color-scheme, or the colorScheme prop: white with a pale globe of dark dots by day, dark with a dark globe of light dots at night, on the same light stage. The canvas reads the globe’s colours from the computed style through a hidden probe, and an unseen 1 ms colour transition tells it to read them again and redraw when the scheme changes; the server’s SVG takes the same tokens by class.
| Prop | Type | Default | Description |
|---|---|---|---|
zones | readonly Zone[] | ZONES | The time zones to list, west to east: an id, a name, an IANA time zone, a standard offset in hours, abbreviations, two or three cities with coordinates, and more places the search knows. |
defaultZone | string | null | 'mountain' | The id of the zone chosen at first, or null for none. |
defaultQuery | string | — | The search’s text at first, as if typed: the list is filtered and its best match pointed at. Setting it turns the demo off. |
defaultActive | string | — | The id of a zone previewed at first, as if under the pointer; the chosen zone stays chosen. Setting it turns the demo off. |
title | string | 'Select your timezone' | The dialog’s heading. |
locale | string | 'en-US' | The language the times are written in, as for Intl.DateTimeFormat. |
onSelect | (zone: Zone) => void | — | Called with the zone when the reader confirms it with the primary button. |
onCancel | () => void | — | Called when the reader cancels (Cancel, or Escape with an empty search); the choice goes back to the last confirmed zone. |
autoplay | boolean | true | While on screen and untouched, a demo pointer browses the list, searches for a city and clicks the globe. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a light stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent,} from 'react';import { homeZone, offsetLabel, readClock, type Reading } from './clock';import { GhostPointer, runDemo } from './ghost-pointer';import { createGlobe, type Globe } from './globe';import { GlobeStill } from './globe-still';import { createListScroll } from './list-scroll';import { bestOf, search } from './search';import type { View } from './sphere';import { useAutoplay } from './use-autoplay';import { ZONES, viewOf, zoneAt, type Zone } from './zones';import styles from './timezone-globe.module.css';/* Timezone globe (timezone-globe.tsx): a dialog on a light stage, the list on the left and the globe on the right; on a narrow stage, the globe sits above the list. Colours are the tokens below, light-dark(light, dark): the dialog takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). By day the dialog is white with a pale globe of dark dots; at night it is dark with a dark globe of light dots. The stage is the same light grey on both. The globe's tokens are read by the canvas too (globe.ts), and by the still before it (globe-still.tsx). */.root { /* The dialog's words: names, then times and places, then the placeholder, the hint and the idle button. */ --ink: #1c1c1e; --muted: #5f5f66; --faint: #6a6a71; /* The dialog, its hairlines, and its shadow on the stage (the sheen is a lit top edge, at night). */ --card: #fff; --line: rgb(0 0 0 / 0.08); --card-edge: rgb(0 0 0 / 0.08); --card-sheen: rgb(255 255 255 / 0); --shadow-near: rgb(0 0 0 / 0.06); --shadow: rgb(0 0 0 / 0.28); /* Fills over the dialog: the search field and Cancel, and Cancel under the pointer; the tag and the idle Select button; a row under the pointer, and the chosen row. */import { landDots } from './land';import { bandRing, clipRing, frameOf, graticule, project, rimArc, unproject, wrap, type Frame, type Outline, type View,} from './sphere';import { viewOf, type Zone } from './zones';/* * The globe, drawn on a 2D canvas: a shaded sphere, a faint graticule, the land as a halftone of * dots, and the time zone shown as a band with its cities. * * - Choosing a zone turns the globe on a spring until its band faces the reader. The spring keeps * its speed when the target changes mid-turn, so hovering down the list makes one smooth move. * - The reader can drag the globe round (it keeps spinning a moment when let go) and click it: the * zone under the pointer is picked. A drag only turns it east or west, so a vertical swipe still/* * The globe's geometry: an orthographic projection, as if the globe were seen from far away, so * only the half facing the reader shows. Shared by the canvas (globe.ts) and the still drawn on * the server (globe-still.tsx). * * - A view is the point of the globe facing the reader, a longitude and a latitude. Points on the * globe are unit vectors; turned into the view they read as x (right), y (up) and z (toward the * reader). x and y are the position on the disc, in radii; z below 0 is the far side. * - A time zone's band is the lune between two meridians. Its outline is clipped to the near * side, and where it runs behind the globe, the outline follows the rim instead. * - The graticule draws the meridians that bound the time zones (every 15°, offset by 7.5°) and a * few parallels. *//** The point of the globe facing the reader, in degrees. */export type View = { lon: number; lat: number };/** A view's rotation, as the sines and cosines it is made of. */export type Frame = { cosLon: number; sinLon: number; cosLat: number; sinLat: number };/** A point on the disc: x and y in radii (y up), z toward the reader. */export type Spot = { x: number; y: number; z: number };/** An outline clipped to the near side. `rim[i]` marks the edge from point i to the next one as/* * The land, as a halftone: points spread evenly over the sphere (a Fibonacci spiral), kept where * they fall on land. * * - The coastlines are coarse outlines written by hand, as longitude and latitude pairs: the * continents, the large islands, and two inland seas cut out of Eurasia. The dots are about * two degrees apart, so a point every few degrees of coast is plenty. * - A point is on land when it falls inside an outline (a ray cast in longitude and latitude) * and in no sea. The spiral and the test are plain arithmetic, so the server and the browser * find the same dots; they are worked out once, the first time they are needed. *//** An outline: longitude, latitude, longitude, latitude… in degrees. */type Outline = readonly number[];/** From the Bering Strait along the Arctic coast, round Hudson Bay, down the Atlantic, round the * Gulf of Mexico to Panama, and back up the Pacific coast to Alaska. */const NORTH_AMERICA: Outline = [ -168.1, 65.6, -166, 66.3, -161.8, 66.6, -163.7, 67.4, -166.8, 68.3, -163, 69.7, -161.9, 70.3, -156.8, 71.3, -152, 70.8, -148.3, 70.3, -143.5, 70.1, -141, 69.7, -137.5, 69, -135, 69.5, -133, 69.5, -129.5, 70, -128, 70.6, -124.7, 70, -121, 69.4, -117.5, 69, -115, 67.8, -110, 68.1, -106, 68.2, -101.5, 67.8, -97, 67.7, -95.8, 69.5, -94.5, 72, -92, 70.5, -90.5, 69, -88, 68.5, -85.5, 69.5, -84.5, 69.9, -81.5, 69.2, -81.3, 67.6, -83, 66.4, -86.2, 66.5, -87.8, 65.3, -90.7, 63.3, -94.1, 61.1, -94.2, 58.8, -92.5, 57.1, -87.6, 56, -82.3, 55.1, -82.3, 52.9, -80.6, 51.3, -79,import { memo, useId, type CSSProperties } from 'react';import { DOT_SIZE, GLOBE_SIZE, dotAlpha, dotScale } from './globe';import { landDots } from './land';import { bandRing, clipRing, frameOf, graticule, project, rimArc, turn, wrap, type View,} from './sphere';import type { Zone } from './zones';import styles from './timezone-globe.module.css';/* * The globe before the canvas takes over: the same sphere, graticule, band and land dots, drawn * as SVG for one view, on the server. It makes the first frame complete before any script runs, * and gives way as soon as the canvas has drawn the same view. * * - The drawing is in thousandths of the globe's radius, so its numbers are whole. The land dots * are zero-length strokes with round caps, one path per size and brightness.import { vectorOf, wrap } from './sphere';/* * The time zones on offer, west to east. Each one has the IANA name its clock is read from, its * standard offset (which places its band on the globe: fifteen degrees of longitude per hour), * its abbreviations, two or three cities drawn on the globe, and more places the search knows. * Every city listed keeps the same clock as its zone all year round. */export type City = { name: string; lat: number; lon: number; /** Where its label sits, beside the dot: right unless it would run into a neighbour's. */ label?: 'left' | 'right' | 'above' | 'below';};export type Zone = { id: string; /** "Mountain Time". */ name: string; /** The IANA time zone its clock is read from, "America/Denver". */ timeZone: string; /** Hours from UTC outside daylight saving time: its band is centred on offset × 15°. */import type { Zone } from './zones';/* * The zones' clocks, read with Intl.DateTimeFormat from their IANA names: the local time, the * offset from UTC right now (daylight saving time included), and the abbreviation. The browser * names the zones it knows ("MDT"); for the others it only gives an offset ("GMT+9"), and the * zone's own abbreviation stands in. *//** A zone's clock at one moment. */export type Reading = { /** "5:01 PM", in the reader's language. */ time: string; /** "MDT". */ abbr: string; /** Minutes from UTC, now. */ offset: number;};const formats = new Map<string, Intl.DateTimeFormat>();/** One formatter per zone and use, made once: they are slow to make and quick to use. */function formatOf(key: string, make: () => Intl.DateTimeFormat) { let format = formats.get(key);import type { Reading } from './clock';import type { Zone } from './zones';/* * The search: a zone matches when the words typed start a word of its name, of one of its * abbreviations, cities, places or IANA name, or when they read as its offset ("+5:30", * "UTC-6"). Accents and case are ignored. When a city or place matched rather than the zone's * own name, the match says which, so the list can show it ("Japan Time · Tokyo"). The list keeps * its order, west to east; `bestOf` picks the match to point at as the reader types. */export type Match = { zone: Zone; /** The city or place that matched, when the zone's name did not. */ place?: string;};/** Lower case, without accents or punctuation: "São Paulo" and "sao paulo" match. */const fold = (text: string) => text .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9+:\u2212-]+/g, ' ')/* * The list's scrolling, by hand. The list is not a scroll container but a box with overflow: * hidden. Safari keeps a scroll container's content in GPU memory as one large texture (and, * inside a scaled-down copy of the page, such as a gallery's thumbnail, it also gives layers to * everything round it, at full size): here that was most of the component's memory. A box with * overflow: hidden costs nothing more than the page, and still scrolls from script: * * - the wheel and the trackpad move it, and at an end leave the page to scroll; * - a finger drags it, and a flick carries on and slows down, as a native list does; * - the keyboard, the search and the demo scroll it to the rows they show (scrollTo); * - a thin bar shows where it is while it moves. *//** A flick slows down with this time constant (ms), about as a native list does. */const GLIDE = 325;/** A finger that moves less than this (px) taps rather than drags. */const TAP_SLOP = 6;/** The bar fades this long (ms) after the list stops. */const BAR_LINGER = 700;export function createListScroll(list: HTMLElement, bar: HTMLElement) { let drag: { id: number; y: number; scroll: number; time: number; speed: number } | null = null; /** The last gesture dragged the list: the click it ends with is not a tap on a row. */ let dragged = false;'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. * * The pointer moves on animation frames, by script, rather than with CSS transitions or the Web * Animations API: an animated element gets a GPU layer of its own, and in a scaled-down copy of * the page (a gallery's thumbnail) Safari then gives full-size layers to everything round it. */export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */ press: (action: () => void) => Promise<void>;};'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The search is a combobox over a listbox, linked by aria-activedescendant: the arrow keys and Page Up and Down move through the zones, Enter chooses, and Escape clears the search, then cancels. Each zone reads plainly: “Mountain Time, 5:01 PM, MDT, your time zone”.
- Choices are announced politely (“Mountain Time selected, 5:01 PM.”, “Time zone set to Mountain Time.”); the demo’s own actions stay silent.
- The globe is decorative: the list carries everything, and dragging or clicking the globe is only a pointer shortcut. It turns east and west only, so a vertical swipe over it still scrolls the page.
- Under reduced motion the globe jumps to each zone, the list scrolls at once and the demo never plays.