Show an event in the visitor's time zone
Convert an event's UTC start time to each visitor's time zone, including the local date and daylight-saving offset.
On this page
Working example
HTML + JavaScriptRun the example
Download HTMLevent-local-time.html
Replace YOUR_PUBLIC_API_KEY in the downloaded file with your public key. Add localhost to that key's allowed domains. In a terminal in the download folder, run:
python3 -m http.server 8000Open localhost:8000/event-local-time.html. When publishing, add your website's hostname to the key. Keep secret keys on your server.
Make the start time obvious
Show a webinar's start time in each visitor's time zone. Someone opening the page in New York, London, or Tokyo sees the same event on their local clock, and can choose another zone if they plan to watch from elsewhere.
The example above loads automatically. Choose another time zone and watch the date change along with the time. Your visitors never need to enter the event's UTC timestamp.
1. Start with your saved event
Your application supplies the title and the scheduled instant:
const EVENT = {
title: 'Designing a better signup',
startsAt: '2026-10-15T18:00:00Z'
};The October 15, 2026 webinar is sample data. Replace its title and timestamp with your event. The Z means UTC: one exact instant that everyone shares. If your scheduling system gives you a local time, resolve it to an instant using its time-zone rules before saving it.
2. Use the visitor's time zone
The browser supplies a useful starting choice:
function visitorZone() {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return canonicalZone(zone) ? zone : null;
}Keep that choice editable. A traveler may be planning for another city, and a browser setting does not establish where someone lives. If the browser has no usable zone, the example asks them to choose one.
3. Convert the scheduled instant
Pass the event and the chosen zone to the Time API:
async function convertEvent(zone, instant, signal) {
const url = new URL('https://api.parseapi.com/time/UTC');
url.search = new URLSearchParams({to:zone, at:instant, key:API_KEY});
const response = await fetch(url, { headers: { 'Parse-Version': '2.0.0' }, signal });
if (!response.ok) throw new Error(response.status === 429
? 'The request limit was reached. Try again later.'
: [401,403].includes(response.status) ? 'Check your public key and allowed hostname.'
: 'The local time is unavailable. Try again.');
return readConversion(await response.json(), zone, instant);
}The to answer contains the local date, time, and offset at the event, including daylight-saving rules for that date. A lookup of today's offset would answer a different question.
4. Put the answer on the event page
function showTime(row) {
const date = new Date(`${row.date}T00:00:00Z`);
document.querySelector('#start').setAttribute('datetime', row.at);
document.querySelector('#clock').textContent = new Intl.DateTimeFormat('en', {
hour:'numeric', minute:'2-digit', timeZone:'UTC'
}).format(new Date(`${row.date}T${row.time}:00Z`));
document.querySelector('#date').textContent = new Intl.DateTimeFormat('en', {
weekday:'long', month:'long', day:'numeric', year:'numeric', timeZone:'UTC'
}).format(date);
document.querySelector('#offset').textContent = `${row.label} · UTC${row.offset}`;
}The visible date matters as much as the clock. This sample starts on Thursday afternoon in New York and Friday morning in Tokyo. The browser formats the returned clock components without converting them into a different time zone again.
Change the saved timestamp to an event near a clock change and try both cities. The complete download cancels old requests when the selection changes, verifies the returned instant, and keeps an unavailable time visible with a retry action.
This displays one scheduled occurrence. For a recurring series, calculate each occurrence from its schedule. To find a shared working window, continue with shared working hours.
Complete example
One HTML file with the markup, styles, and JavaScript. Replace YOUR_PUBLIC_API_KEY with your public key, then run it on a domain you have added to that key.
View and copy the complete source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Show an event in the visitor's time zone</title>
<style>
:root { color-scheme: light dark; --bg:#fff; --ink:#152029; --muted:#5e6b75; --line:#d9e2e7; --field:#f6f9fa; --accent:#007c91; --error:#b42335; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg:#0b1015; --ink:#eff7fa; --muted:#94a6b2; --line:#2c3942; --field:#121b23; --accent:#67e8f9; --error:#fda4af; } }
:root[data-theme="dark"] { color-scheme:dark; --bg:#0b1015; --ink:#eff7fa; --muted:#94a6b2; --line:#2c3942; --field:#121b23; --accent:#67e8f9; --error:#fda4af; }
:root[data-theme="light"] { color-scheme:light; }
* { box-sizing:border-box; }
[hidden] { display:none !important; }
body { margin:0; padding:28px; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui,sans-serif; }
main { max-width:520px; margin:auto; }
p { margin:0; }
.eyebrow { color:var(--accent); font-size:11px; letter-spacing:.08em; text-transform:uppercase; }
h1 { margin:12px 0 8px; font-size:clamp(25px,6vw,34px); font-weight:600; letter-spacing:-.035em; line-height:1.15; }
.intro { color:var(--muted); max-width:34ch; }
.schedule { margin:28px 0 24px; border-block:1px solid var(--line); padding:24px 0; }
.label { font-size:13px; color:var(--muted); }
#clock { display:block; font-size:clamp(40px,10vw,58px); line-height:1.15; font-weight:600; letter-spacing:-.05em; }
#date { display:block; margin-top:5px; font-size:16px; }
#offset { margin-top:8px; color:var(--muted); font-size:12px; }
label { display:block; font-size:12px; color:var(--muted); }
select { display:block; width:100%; min-width:0; min-height:44px; margin-top:7px; padding:10px 40px 10px 12px; border:1px solid var(--line); border-radius:7px; background:var(--field); color:var(--ink); font:inherit; }
select:not([multiple]):where(:not([size]), [size="0"], [size="1"]) { -webkit-appearance:none; appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m4 6 4 4 4-4' fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 12px center; background-size:16px; }
#status { margin-top:12px; color:var(--muted); font-size:12px; }
#status[data-error="true"] { color:var(--error); }
button { margin-top:12px; min-height:44px; padding:0; border:0; background:none; color:var(--accent); font:inherit; cursor:pointer; }
:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
@media(max-width:380px) { body { padding:20px; } }
</style>
</head>
<body>
<main aria-busy="true">
<p class="eyebrow">Sample webinar · 45 minutes</p>
<h1 id="event-title">Designing a better signup</h1>
<p class="intro">A practical session on making the first few minutes feel effortless.</p>
<!-- example:form -->
<section class="schedule" aria-label="Event start time" aria-live="polite" aria-atomic="true">
<p class="label">Starts at</p>
<time id="start"><strong id="clock">—</strong><span id="date">Loading your local time...</span></time>
<p id="offset"></p>
</section>
<label>Time zone<select id="zone" aria-label="Time zone"></select></label>
<p id="status" role="status" aria-live="polite" data-error="false"></p>
<button id="retry" type="button" hidden>Try again</button>
<!-- /example:form -->
</main>
<script>
const API_KEY = 'YOUR_PUBLIC_API_KEY';
// example:instant
const EVENT = {
title: 'Designing a better signup',
startsAt: '2026-10-15T18:00:00Z'
};
// /example:instant
const ZONES = {
'America/Los_Angeles': { label:'Los Angeles' },
'America/Denver': { label:'Denver' },
'America/New_York': { label:'New York' },
'Europe/London': { label:'London' },
'Europe/Paris': { label:'Paris' },
'Asia/Kolkata': { label:'Kolkata' },
'Asia/Tokyo': { label:'Tokyo' },
'Australia/Sydney': { label:'Sydney' }
};
const main = document.querySelector('main');
const select = document.querySelector('#zone');
const status = document.querySelector('#status');
const retry = document.querySelector('#retry');
document.querySelector('#event-title').textContent = EVENT.title;
let controller;
let revision = 0;
function canonicalZone(zone) {
if (typeof zone !== 'string' || !zone.trim()) return null;
try { return new Intl.DateTimeFormat('en', {timeZone:zone}).resolvedOptions().timeZone; }
catch { return null; }
}
function eventInstant(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value)
|| !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value.replace('Z', '.000Z')) {
throw new Error('Set the event to a valid UTC timestamp.');
}
return value;
}
// example:destination
function visitorZone() {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return canonicalZone(zone) ? zone : null;
}
// /example:destination
// example:request
async function convertEvent(zone, instant, signal) {
const url = new URL('https://api.parseapi.com/time/UTC');
url.search = new URLSearchParams({to:zone, at:instant, key:API_KEY});
const response = await fetch(url, { headers: { 'Parse-Version': '2.0.0' }, signal });
if (!response.ok) throw new Error(response.status === 429
? 'The request limit was reached. Try again later.'
: [401,403].includes(response.status) ? 'Check your public key and allowed hostname.'
: 'The local time is unavailable. Try again.');
return readConversion(await response.json(), zone, instant);
}
// /example:request
function readConversion(data, zone, instant) {
const fail = () => new Error('Could not verify the converted time. Try again.');
const target = data?.to;
if (!canonicalZone(zone) || data?.timezone !== 'UTC' || data.at !== instant.replace('Z', '+00:00')
|| !target || canonicalZone(target.timezone) !== canonicalZone(zone)
|| !(target.abbreviation === null || typeof target.abbreviation === 'string' && target.abbreviation.trim())
|| typeof target.offset !== 'string'
|| typeof target.at !== 'string') throw fail();
const match = target.at.match(/^(\d{4}-\d{2}-\d{2})T((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)([+-])(\d{2}):([0-5]\d)$/);
if (!match) throw fail();
const [, date, time, sign, hours, minutes] = match;
const offset = (Number(hours) * 60 + Number(minutes)) * (sign === '-' ? -1 : 1);
const wall = `${date}T${time}`;
const wallMs = Date.parse(`${wall}Z`);
if (!Number.isFinite(wallMs) || new Date(wallMs).toISOString().slice(0, 19) !== wall
|| Math.abs(offset) > 840
|| target.offset !== `${sign}${hours}:${minutes}` || Date.parse(target.at) !== Date.parse(instant)) throw fail();
const weekday = new Intl.DateTimeFormat('en', { weekday: 'short', timeZone: 'UTC' }).format(new Date(wallMs));
return { label: ZONES[zone]?.label || zone.replaceAll('_', ' '), timezone: target.timezone, at: target.at, date, time: time.slice(0, 5), weekday,
offset: target.offset, abbreviation: target.abbreviation };
}
// example:result
function showTime(row) {
const date = new Date(`${row.date}T00:00:00Z`);
document.querySelector('#start').setAttribute('datetime', row.at);
document.querySelector('#clock').textContent = new Intl.DateTimeFormat('en', {
hour:'numeric', minute:'2-digit', timeZone:'UTC'
}).format(new Date(`${row.date}T${row.time}:00Z`));
document.querySelector('#date').textContent = new Intl.DateTimeFormat('en', {
weekday:'long', month:'long', day:'numeric', year:'numeric', timeZone:'UTC'
}).format(date);
document.querySelector('#offset').textContent = `${row.label} · UTC${row.offset}`;
}
// /example:result
function clearTime() {
document.querySelector('#start').removeAttribute('datetime');
document.querySelector('#clock').textContent = '—';
document.querySelector('#date').textContent = 'Local time unavailable';
document.querySelector('#offset').textContent = '';
}
async function refresh() {
revision += 1;
controller?.abort();
const current = revision;
const request = new AbortController();
controller = request;
clearTime();
retry.hidden = true;
status.dataset.error = 'false';
if (!canonicalZone(select.value)) {
status.textContent = 'Choose a time zone to see the start time.';
main.setAttribute('aria-busy', 'false');
return;
}
main.setAttribute('aria-busy', 'true');
document.querySelector('#date').textContent = 'Loading your local time...';
status.textContent = '';
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
try {
if (!API_KEY.startsWith('parse_public_')) throw new Error('Add your public API key and allow this hostname.');
const row = await convertEvent(select.value, eventInstant(EVENT.startsAt), request.signal);
if (current !== revision) return;
if (request.signal.aborted) throw new Error('The request was cancelled.');
showTime(row);
} catch (error) {
if (current !== revision) return;
clearTime();
status.dataset.error = 'true';
status.textContent = timedOut ? 'The request timed out. Try again.' : error instanceof TypeError
? 'Could not reach ParseAPI. Check your connection.' : error instanceof SyntaxError
? 'The response could not be read. Try again.' : error instanceof Error ? error.message : 'The local time is unavailable.';
retry.hidden = false;
} finally {
clearTimeout(timeout);
if (current === revision) { main.setAttribute('aria-busy', 'false'); controller = undefined; }
}
}
let detected;
try { detected = visitorZone(); } catch { detected = null; }
if (detected && !Object.hasOwn(ZONES, detected)) ZONES[detected] = {label:detected.replaceAll('_', ' ')};
const placeholder = document.createElement('option');
placeholder.value = ''; placeholder.textContent = 'Choose a time zone';
select.append(placeholder);
for (const [id, zone] of Object.entries(ZONES)) {
const option = document.createElement('option');
option.value = id; option.textContent = `${zone.label}${id === detected ? ' (your time zone)' : ''}`;
select.append(option);
}
select.value = detected || '';
select.addEventListener('change', refresh);
retry.addEventListener('click', refresh);
refresh();
</script>
</body>
</html>
Need a key? Set up this browser example. For every field and parameter, see Time API reference.