Order tracking page
Where the order is now. A timeline with every scan, a drawn map with the van on its way, a lookup for guests and several parcels.
Where the order is right now
After paying, the most asked question is where the order is. An order tracking page answers it on the shop's own site, with the delivery window large and every stage in plain words, so support gets fewer questions.
Timeline shows the delivery window, five stages that fill one after another with the current one pulsing, every scan below, a text alert to switch on and the copyable tracking number. Live map drives a van in from the depot on a drawn city map, the minutes count down and a bar shows the stops ahead. Guest lookup takes the order number and email or postcode, each with its own error that says what to fix. Several parcels splits one order into parcels as tabs with arrow keys, each with its stage and its contents.
Link the tracking page from every shipping email, then the visit stays in the shop. Put the delivery window first, that is what people look for. Show several parcels as one order, then a partial delivery reads as progress.
Variants
Details
What Order tracking brings along, read from the registry and the source.
- Category
- Orders and returns
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/order-tracking.tsx
- Brings along
- utilskitbuttoniconproduct-imageinput
- npm packages
- None
- Languages
- English and German, 49 texts of its own
- Studio
- Adjust in code
- In collections
- Online shopShop checkout
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The OrderTrackingProps interface from components/ui/order-tracking.tsx, with the comments from the code.
variantoptionalOrderTrackingVarianttimelinemaplookupparcels
orderoptionalTrackingOrderordersoptionalTrackingOrder[]lookup: the orders the form can find. Or pass onLookup.
onLookupoptional(number: string, contact: string) => Promise<TrackingOrder | null> | TrackingOrder | nullliveoptional{ stopsAway: number; minutes: number; driver?: string }map: stops before yours and minutes until arrival
linksoptional{ help?: string; reschedule?: string }Links for the actions
Built-in texts
Order tracking writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 49 texts
| Key | English | German |
|---|---|---|
| ordered | English Ordered | German Bestellt |
| packed | English Packed | German Verpackt |
| shipped | English On its way | German Unterwegs |
| out | English Out for delivery | German In Zustellung |
| delivered | English Delivered | German Zugestellt |
| order | English Order | German Bestellung |
| tracking | English Tracking number | German Sendungsnummer |
| copy | English Copy tracking number | German Sendungsnummer kopieren |
| copied | English Copied | German Kopiert |
| arrives | English Arrives | German Ankunft |
| arrived | English Delivered | German Zugestellt |
| between | English between {from} and {to} | German zwischen {from} und {to} |
| history | English Shipment history | German Sendungsverlauf |
| showAll | English Show all {n} updates | German Alle {n} Meldungen zeigen |
| showLess | English Show fewer updates | German Weniger Meldungen zeigen |
| notify | English Text me before it arrives | German Per SMS vorher Bescheid geben |
| notifyOn | English We will text you about 30 minutes before | German Wir schreiben dir etwa 30 Minuten vorher |
| reschedule | English Change delivery | German Zustellung ändern |
| help | English Get help with this order | German Hilfe zu dieser Bestellung |
| deliverTo | English Delivering to | German Lieferung an |
| stopsAway | English {n} stops before yours | German Noch {n} Stopps vor dir |
| oneStop | English One stop before yours | German Noch ein Stopp vor dir |
| nextStop | English You are the next stop | German Du bist der nächste Stopp |
| minutes | English about {n} min | German etwa {n} Min. |
| liveNote | English Live position, updated every minute | German Live-Position, jede Minute aktualisiert |
| driver | English Your driver | German Dein Fahrer |
| message | English Message | German Nachricht |
| depot | English Depot | German Depot |
| home | English You | German Du |
| lookupTitle | English Track your order | German Bestellung verfolgen |
| lookupText | English Your order number is all it takes, you will find it in the confirmation email. | German Deine Bestellnummer genügt, sie steht in der Bestätigungsmail. |
| number | English Order number | German Bestellnummer |
| numberHint | English e.g. NH-48210 | German z. B. NH-48210 |
| contact | English Email or postcode | German E-Mail oder Postleitzahl |
| find | English Track order | German Bestellung verfolgen |
| finding | English Searching | German Wird gesucht |
| eNumber | English Enter your order number, it starts with two letters. | German Gib deine Bestellnummer ein, sie beginnt mit zwei Buchstaben. |
| eNumberFormat | English Order numbers look like NH-48210. Check the confirmation email. | German Bestellnummern sehen aus wie NH-48210. Schau in die Bestätigungsmail. |
| eContact | English Enter the email of the order or the postcode of the delivery address. | German Gib die E-Mail der Bestellung oder die Postleitzahl der Lieferadresse ein. |
| eNotFound | English We found no order with these details. Check the number and the email or postcode. | German Zu diesen Angaben finden wir keine Bestellung. Prüf die Nummer und die E-Mail oder Postleitzahl. |
| another | English Track another order | German Andere Bestellung verfolgen |
| latest | English Latest update | German Letzte Meldung |
| parcel | English Parcel | German Paket |
| parcelsOf | English {n} parcels | German {n} Pakete |
| inside | English In this parcel | German In diesem Paket |
| progress | English Progress | German Fortschritt |
| current | English current stage | German aktuelle Etappe |
| done | English done | German erledigt |
| next | English to come | German folgt |
Accessibility
Read from the source of the component.
- Order tracking handles the keys Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-checked, aria-controls, aria-current, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby and aria-selected, so screen readers announce purpose and state.
- Uses the roles alert, img, status, switch, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 8 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 856 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatTime, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { ProductMedia, ShopMotion } from '@/components/ui/product-image';
/*
* Where is my order. Five stages from ordered to delivered.
*
* timeline the delivery window large, the stages as a bar (a column on phones), every scan below
* map a drawn map with the route, the van on its way and the stops still ahead
* lookup order number and email or postcode, then the status, for guests without an account
* parcels an order in several parcels as tabs, each with its own progress and contents
*
* Times are "HH:MM" and dates "YYYY-MM-DD", both shown in the format of the language.
*/
export type OrderTrackingVariant = 'timeline' | 'map' | 'lookup' | 'parcels';
export type TrackingStage = 'ordered' | 'packed' | 'shipped' | 'out' | 'delivered';
export interface TrackingEvent {
/** "YYYY-MM-DD" */
date: string;
/** "HH:MM" */
time: string;
text: string;
place?: string;
stage?: TrackingStage;
}
export interface TrackingItem {
name: string;
quantity?: number;
variant?: string;
image?: React.ReactNode;
}
export interface TrackingParcel {
id: string;
/** e.g. "Parcel 1" or what is inside */
label?: string;
trackingNumber: string;
stage: TrackingStage;
/** Expected delivery: a date and a window */
eta?: { date: string; from?: string; to?: string };
/** Newest first */
events: TrackingEvent[];
items?: TrackingItem[];
}
export interface TrackingOrder {
number: string;
/** For the lookup check */
email?: string;
postcode?: string;
/** "YYYY-MM-DD" */
placed?: string;
/** Delivery address, one line */
address?: string;
parcels: TrackingParcel[];
}
export interface OrderTrackingProps {
variant?: OrderTrackingVariant;
order?: TrackingOrder;
/** lookup: the orders the form can find. Or pass onLookup. */
orders?: TrackingOrder[];
onLookup?: (number: string, contact: string) => Promise<TrackingOrder | null> | TrackingOrder | null;
/** map: stops before yours and minutes until arrival */
live?: { stopsAway: number; minutes: number; driver?: string };
/** Links for the actions */
links?: { help?: string; reschedule?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
ordered: 'Ordered',
packed: 'Packed',
shipped: 'On its way',
out: 'Out for delivery',
delivered: 'Delivered',
order: 'Order',
tracking: 'Tracking number',
copy: 'Copy tracking number',
copied: 'Copied',
arrives: 'Arrives',
arrived: 'Delivered',
between: 'between {from} and {to}',
history: 'Shipment history',
showAll: 'Show all {n} updates',
showLess: 'Show fewer updates',
notify: 'Text me before it arrives',
notifyOn: 'We will text you about 30 minutes before',
reschedule: 'Change delivery',
help: 'Get help with this order',
deliverTo: 'Delivering to',
stopsAway: '{n} stops before yours',
oneStop: 'One stop before yours',
nextStop: 'You are the next stop',
minutes: 'about {n} min',
liveNote: 'Live position, updated every minute',
driver: 'Your driver',
message: 'Message',
depot: 'Depot',
home: 'You',
lookupTitle: 'Track your order',
lookupText: 'Your order number is all it takes, you will find it in the confirmation email.',
number: 'Order number',
numberHint: 'e.g. NH-48210',
contact: 'Email or postcode',
find: 'Track order',
finding: 'Searching',
eNumber: 'Enter your order number, it starts with two letters.',
eNumberFormat: 'Order numbers look like NH-48210. Check the confirmation email.',
eContact: 'Enter the email of the order or the postcode of the delivery address.',
eNotFound: 'We found no order with these details. Check the number and the email or postcode.',
another: 'Track another order',
latest: 'Latest update',
parcel: 'Parcel',
parcelsOf: '{n} parcels',
inside: 'In this parcel',
progress: 'Progress',
current: 'current stage',
done: 'done',
next: 'to come',
},
de: {
ordered: 'Bestellt',
packed: 'Verpackt',
shipped: 'Unterwegs',
out: 'In Zustellung',
delivered: 'Zugestellt',
order: 'Bestellung',
tracking: 'Sendungsnummer',
copy: 'Sendungsnummer kopieren',
copied: 'Kopiert',
arrives: 'Ankunft',
arrived: 'Zugestellt',
between: 'zwischen {from} und {to}',
history: 'Sendungsverlauf',
showAll: 'Alle {n} Meldungen zeigen',
showLess: 'Weniger Meldungen zeigen',
notify: 'Per SMS vorher Bescheid geben',
notifyOn: 'Wir schreiben dir etwa 30 Minuten vorher',
reschedule: 'Zustellung ändern',
help: 'Hilfe zu dieser Bestellung',
deliverTo: 'Lieferung an',
stopsAway: 'Noch {n} Stopps vor dir',
oneStop: 'Noch ein Stopp vor dir',
nextStop: 'Du bist der nächste Stopp',
minutes: 'etwa {n} Min.',
liveNote: 'Live-Position, jede Minute aktualisiert',
driver: 'Dein Fahrer',
message: 'Nachricht',
depot: 'Depot',
home: 'Du',
lookupTitle: 'Bestellung verfolgen',
lookupText: 'Deine Bestellnummer genügt, sie steht in der Bestätigungsmail.',
number: 'Bestellnummer',
numberHint: 'z. B. NH-48210',
contact: 'E-Mail oder Postleitzahl',
find: 'Bestellung verfolgen',
finding: 'Wird gesucht',
eNumber: 'Gib deine Bestellnummer ein, sie beginnt mit zwei Buchstaben.',
eNumberFormat: 'Bestellnummern sehen aus wie NH-48210. Schau in die Bestätigungsmail.',
eContact: 'Gib die E-Mail der Bestellung oder die Postleitzahl der Lieferadresse ein.',
eNotFound: 'Zu diesen Angaben finden wir keine Bestellung. Prüf die Nummer und die E-Mail oder Postleitzahl.',
another: 'Andere Bestellung verfolgen',
latest: 'Letzte Meldung',
parcel: 'Paket',
parcelsOf: '{n} Pakete',
inside: 'In diesem Paket',
progress: 'Fortschritt',
current: 'aktuelle Etappe',
done: 'erledigt',
next: 'folgt',
},
};
type Texts = (typeof TEXTS)['en'];
const STAGES: TrackingStage[] = ['ordered', 'packed', 'shipped', 'out', 'delivered'];
const STAGE_ICON: Record<TrackingStage, IconName> = { ordered: 'receipt', packed: 'package', shipped: 'truck', out: 'map-pin', delivered: 'home' };
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const idx = (s: TrackingStage) => STAGES.indexOf(s);
function formatDate(locale: Locale, iso: string, style: 'long' | 'short' = 'short') {
const d = new Date(iso + 'T12:00:00Z');
return new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', style === 'long' ? { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' } : { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(d);
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
/** The five stages as a bar from 768 px and as a column below */
function Stages({ stage, t, compact }: { stage: TrackingStage; t: Texts; compact?: boolean }) {
const at = idx(stage);
const [ref, inView] = useInView<HTMLOListElement>();
const pct = (at / (STAGES.length - 1)) * 100;
return (
<ol ref={ref} aria-label={t.progress} className={cn('relative', compact ? 'grid grid-cols-5' : 'max-md:space-y-0 md:grid md:grid-cols-5')}>
{/* The line under the dots: across from 768 px, down on phones */}
<span aria-hidden className={cn('absolute bg-track', compact ? 'left-[10%] right-[10%] top-4 h-0.5' : 'bottom-5 left-[19px] top-5 w-0.5 md:bottom-auto md:left-[10%] md:right-[10%] md:top-5 md:h-0.5 md:w-auto')}>
<span
className={cn('absolute left-0 top-0 bg-primary transition-[width,height] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none', compact ? 'h-full' : 'w-full md:h-full md:w-auto max-md:hidden')}
style={{ width: `${inView ? pct : 0}%` }}
/>
{!compact && <span className="absolute left-0 top-0 w-full bg-primary transition-[height] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none md:hidden" style={{ height: `${inView ? pct : 0}%` }} />}
</span>
{STAGES.map((s, i) => {
const done = i < at;
const now = i === at;
return (
<li key={s} className={cn('relative flex items-center gap-4', compact ? 'flex-col gap-2 text-center' : 'py-2 md:flex-col md:gap-3 md:py-0 md:text-center')} aria-current={now ? 'step' : undefined}>
<span
className={cn(
'relative grid shrink-0 place-items-center rounded-full border-2 transition-[background-color,border-color,color] duration-500',
compact ? 'size-8' : 'size-10',
done || now ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background text-muted-foreground',
)}
style={{ transitionDelay: inView ? `${200 + i * 220}ms` : '0ms' }}
>
{now && i < STAGES.length - 1 && <span aria-hidden className="absolute -inset-1.5 animate-kit-pulse rounded-full border-2 border-primary/40 motion-reduce:animate-none" />}
<Icon name={done ? 'check' : STAGE_ICON[s]} size={compact ? 14 : 17} strokeWidth={done ? 2.4 : 1.8} />
</span>
<span className={cn('text-[13px] leading-tight', compact && 'max-sm:text-[11px]', now ? 'font-semibold text-foreground' : done ? 'text-foreground' : 'text-muted-foreground')}>
{t[s]}
<span className="sr-only">, {now ? t.current : done ? t.done : t.next}</span>
</span>
</li>
);
})}
</ol>
);
}
function CopyNumber({ value, t }: { value: string; t: Texts }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
return (
<span className="inline-flex items-center gap-1">
<span className="text-[13px] tabular-nums tracking-[0.04em] text-foreground">{value}</span>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(value).catch(() => {});
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1600);
}}
aria-label={copied ? t.copied : t.copy}
className="relative grid size-8 place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground"
>
<Icon name={copied ? 'check' : 'copy'} size={14} className={cn(copied && 'animate-[kitshop-pop_0.35s_var(--ease-kit)] text-success')} />
</button>
<span role="status" className="sr-only">
{copied ? t.copied : ''}
</span>
</span>
);
}
function Eta({ parcel, t, size = 'lg' }: { parcel: TrackingParcel; t: Texts; size?: 'lg' | 'md' }) {
const locale = useLocale();
const delivered = parcel.stage === 'delivered';
const e = parcel.eta;
const last = parcel.events[0];
return (
<div>
<p className="text-[13px] font-medium text-muted-foreground">{delivered ? t.arrived : t.arrives}</p>
<p className={cn('mt-1 text-balance font-semibold tracking-[-0.02em] text-foreground', size === 'lg' ? 'text-h3' : 'text-[19px]')}>
{delivered && last ? formatDate(locale, last.date, 'long') : e ? formatDate(locale, e.date, 'long') : ''}
</p>
{!delivered && e?.from && e.to && <p className="mt-1 text-[15px] text-foreground">{fill(t.between, { from: formatTime(e.from, locale), to: formatTime(e.to, locale) })}</p>}
{delivered && last && <p className="mt-1 text-[15px] text-foreground">{formatTime(last.time, locale)}{last.place ? ` · ${last.place}` : ''}</p>}
</div>
);
}
function Events({ events, t, initial = 3 }: { events: TrackingEvent[]; t: Texts; initial?: number }) {
const locale = useLocale();
const [all, setAll] = useState(false);
const shown = all ? events : events.slice(0, initial);
const list = useId();
return (
<div>
<ol id={list} className="relative">
{shown.map((e, i) => {
const day = i === 0 || shown[i - 1].date !== e.date;
return (
<li key={`${e.date}${e.time}${i}`} className={cn('relative grid grid-cols-[4.75rem_1.5rem_minmax(0,1fr)] gap-x-2 sm:grid-cols-[7.5rem_1.5rem_minmax(0,1fr)] sm:gap-x-3', i >= initial && 'animate-[kitshop-in_0.4s_var(--ease-kit)_both]')} style={i >= initial ? { animationDelay: `${(i - initial) * 50}ms` } : undefined}>
<span className="pt-0.5 text-right text-[12px] leading-tight tabular-nums text-muted-foreground">
{day && <span className="block font-medium text-foreground">{formatDate(locale, e.date)}</span>}
{formatTime(e.time, locale)}
</span>
<span className="relative flex justify-center" aria-hidden>
{i < shown.length - 1 && <span className="absolute bottom-0 top-3 w-px bg-border" />}
<span className={cn('relative mt-1 size-2.5 rounded-full', i === 0 ? 'bg-primary ring-4 ring-[color-mix(in_oklab,var(--primary)_18%,transparent)]' : 'bg-border-strong')} />
</span>
<span className="pb-5">
<span className={cn('block text-[14px] leading-snug', i === 0 ? 'font-medium text-foreground' : 'text-foreground')}>{e.text}</span>
{e.place && <span className="mt-0.5 block text-[12px] text-muted-foreground">{e.place}</span>}
</span>
</li>
);
})}
</ol>
{events.length > initial && (
<button type="button" onClick={() => setAll(!all)} aria-expanded={all} aria-controls={list} className="ml-[5.25rem] inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent max-sm:h-11 sm:ml-[8.75rem]">
{all ? t.showLess : fill(t.showAll, { n: events.length })}
<Icon name="chevron-down" size={15} className={cn('transition-transform motion-reduce:transition-none', all && 'rotate-180')} />
</button>
)}
</div>
);
}
function NotifySwitch({ t }: { t: Texts }) {
const [on, setOn] = useState(false);
const id = useId();
return (
<div className="flex items-start justify-between gap-4">
<div>
<p id={id} className="text-[14px] font-medium text-foreground">
{t.notify}
</p>
<p className={cn('mt-0.5 text-[12px] text-muted-foreground transition-opacity', !on && 'opacity-0')} aria-hidden={!on}>
{t.notifyOn}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={on}
aria-labelledby={id}
onClick={() => setOn(!on)}
className={cn('relative mt-0.5 h-7 w-12 shrink-0 rounded-full transition-colors duration-300 after:absolute after:-inset-2', on ? 'bg-primary' : 'bg-track')}
>
<span className={cn('absolute left-0.5 top-0.5 size-6 rounded-full bg-white shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', on && 'translate-x-5')} />
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* timeline */
/* ------------------------------------------------------------------ */
function Timeline(props: OrderTrackingProps & { t: Texts }) {
const { t } = props;
const order = props.order;
const p = order?.parcels[0];
if (!order || !p) return null;
return (
<section className={cn('py-12 sm:py-16 lg:py-20', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mx-auto max-w-[58rem]">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-2">
<p className="text-[13px] text-muted-foreground">
{t.order} <span className="font-medium tabular-nums text-foreground">{order.number}</span>
</p>
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
{t.tracking} <CopyNumber value={p.trackingNumber} t={t} />
</p>
</div>
<div className="mt-4 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div className="grid grid-cols-1 gap-6 p-5 sm:p-8 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<div className="kit-rise">
<Eta parcel={p} t={t} />
</div>
{order.address && (
<p className="kit-rise flex items-start gap-2 text-[13px] text-muted-foreground md:max-w-[16rem] md:text-right" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
<Icon name="map-pin" size={15} className="mt-0.5 shrink-0 md:order-2" />
<span>
<span className="block font-medium text-foreground">{t.deliverTo}</span>
{order.address}
</span>
</p>
)}
</div>
<div className="border-t border-border px-5 py-6 sm:px-8 sm:py-8">
<Stages stage={p.stage} t={t} />
</div>
</div>
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-12">
<div>
<h2 className="mb-5 text-[16px] font-semibold text-foreground">{t.history}</h2>
<Events events={p.events} t={t} />
</div>
<aside className="space-y-5 rounded-2xl border border-border bg-surface p-5 lg:self-start">
<NotifySwitch t={t} />
<div className="grid gap-2 border-t border-border pt-5">
<Button href={props.links?.reschedule ?? '#'} variant="outline" iconLeft="calendar" className="w-full">
{t.reschedule}
</Button>
<Button href={props.links?.help ?? '#'} variant="ghost" iconLeft="help-circle" className="w-full">
{t.help}
</Button>
</div>
</aside>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* map */
/* ------------------------------------------------------------------ */
const ROUTE: [number, number][] = [
[118, 382], [118, 300], [250, 300], [250, 196], [410, 196], [410, 120], [560, 120], [560, 236], [682, 236], [682, 150],
];
function pointAt(f: number) {
const segs = ROUTE.slice(1).map((p, i) => Math.hypot(p[0] - ROUTE[i][0], p[1] - ROUTE[i][1]));
const total = segs.reduce((a, b) => a + b, 0);
let left = Math.max(0, Math.min(1, f)) * total;
for (let i = 0; i < segs.length; i++) {
if (left <= segs[i]) {
const k = segs[i] ? left / segs[i] : 0;
const [x0, y0] = ROUTE[i];
const [x1, y1] = ROUTE[i + 1];
return { x: round(x0 + (x1 - x0) * k, 1), y: round(y0 + (y1 - y0) * k, 1), dx: x1 - x0, dy: y1 - y0 };
}
left -= segs[i];
}
const [x, y] = ROUTE[ROUTE.length - 1];
return { x, y, dx: 1, dy: 0 };
}
/** The drawing, in map units. Roads and water reach past the frame, so a wider or taller window never shows an edge. */
function MapDrawing({ f, t, viewBox, labelScale = 1 }: { f: number; t: Texts; viewBox: string; labelScale?: number }) {
const at = pointAt(f);
const pts = ROUTE.map((p) => p.join(' ')).join(' L');
const block = { fill: 'color-mix(in oklab, var(--foreground) 5%, var(--surface-2))' };
const park = { fill: 'color-mix(in oklab, var(--success) 15%, var(--surface-2))' };
const road = { fill: 'none', stroke: 'var(--background)', strokeLinecap: 'round' as const };
const [dx, dy] = ROUTE[0];
const [hx, hy] = ROUTE[ROUTE.length - 1];
const label = (x: number, y: number, text: string, strong?: boolean) => {
const w = Math.round(text.length * 7.4 + 20);
return (
<g transform={`translate(${x} ${y}) scale(${labelScale})`}>
<rect x={-w / 2} y="-13" width={w} height="26" rx="7" style={{ fill: strong ? 'var(--primary)' : 'var(--background)', filter: 'drop-shadow(0 3px 6px rgb(0 0 0 / 0.2))' }} />
<text y="4.5" textAnchor="middle" fontSize="13" fontWeight={strong ? 700 : 600} style={{ fill: strong ? 'var(--primary-foreground)' : 'var(--foreground)' }}>
{text}
</text>
</g>
);
};
return (
<svg viewBox={viewBox} preserveAspectRatio="xMidYMid meet" className="absolute inset-0 size-full">
<rect x="-800" y="-400" width="2400" height="1300" style={{ fill: 'var(--surface-2)' }} />
<path d="M-800 430 C -300 380, -100 470, 140 420 S 300 470, 520 420 S 760 380, 1600 400 V 900 H -800 Z" style={{ fill: 'color-mix(in oklab, var(--primary) 16%, var(--surface-2))' }} />
<rect x="300" y="230" width="90" height="56" rx="10" style={park} />
<circle cx="740" cy="60" r="70" style={park} />
<rect x="-200" y="120" width="120" height="70" rx="12" style={park} />
{[-60, 60, 120, 196, 236, 300, 360].map((y) => <path key={`h${y}`} d={`M-800 ${y} H1600`} style={{ ...road, strokeWidth: y === 196 ? 13 : 7 }} />)}
{[-250, -150, -40, 40, 118, 190, 250, 330, 410, 480, 560, 620, 682, 750, 840, 930].map((x) => <path key={`v${x}`} d={`M${x} -400 V430`} style={{ ...road, strokeWidth: x === 410 ? 13 : 7 }} />)}
{[[136, 316, 40, 28], [266, 132, 50, 48], [424, 132, 40, 48], [496, 246, 50, 40], [576, 132, 30, 38], [636, 246, 32, 40], [200, 246, 36, 40], [700, 166, 36, 52], [496, 316, 110, 30], [136, 132, 40, 48], [-120, 246, 60, 40], [-20, 132, 44, 48], [770, 246, 50, 40], [860, 132, 50, 48]].map(([x, y, w, h], i) => (
<rect key={i} x={x} y={y} width={w} height={h} rx="4" style={block} />
))}
<path d={`M${pts}`} fill="none" strokeWidth="6" strokeLinejoin="round" strokeLinecap="round" strokeDasharray="0.012 0.012" pathLength={1} style={{ stroke: 'color-mix(in oklab, var(--primary) 45%, var(--surface-2))' }} />
<path d={`M${pts}`} fill="none" strokeWidth="6" strokeLinejoin="round" strokeLinecap="round" pathLength={1} strokeDasharray={`${round(f, 4)} 1`} style={{ stroke: 'var(--primary)' }} />
<g transform={`translate(${dx} ${dy})`}>
<rect x="-14" y="-14" width="28" height="28" rx="7" style={{ fill: 'var(--foreground)' }} />
<path d="M-7 4V-2l7-5 7 5v6Z" fill="none" strokeWidth="2" strokeLinejoin="round" style={{ stroke: 'var(--background)' }} />
</g>
{label(dx, dy + 20 + 14 * labelScale, t.depot)}
<g transform={`translate(${hx} ${hy})`}>
<circle r="30" className="animate-kit-pulse motion-reduce:animate-none" style={{ fill: 'color-mix(in oklab, var(--primary) 22%, transparent)' }} />
<circle r="13" style={{ fill: 'var(--primary)', stroke: 'var(--background)', strokeWidth: 4 }} />
</g>
{label(hx, hy - 32 - 14 * labelScale, t.home, true)}
<g transform={`translate(${at.x} ${at.y})`}>
<circle r="21" style={{ fill: 'var(--background)', filter: 'drop-shadow(0 4px 8px rgb(0 0 0 / 0.25))' }} />
<g transform={at.dx < 0 ? 'scale(-1 1)' : undefined}>
<path d="M-11 5V-6h12l5 5h4v6Z" strokeWidth="2.2" strokeLinejoin="round" fill="none" style={{ stroke: 'var(--foreground)' }} />
<circle cx="-6" cy="6.5" r="2.6" style={{ fill: 'var(--foreground)' }} />
<circle cx="6" cy="6.5" r="2.6" style={{ fill: 'var(--foreground)' }} />
</g>
</g>
</svg>
);
}
function RouteMap({ progress, t }: { progress: number; t: Texts }) {
const [ref, inView] = useInView<HTMLDivElement>();
const [f, setF] = useState(progress);
// Drive in from the depot once the map is on screen
useEffect(() => {
if (!inView || prefersReducedMotion()) return setF(progress);
let raf = 0;
const t0 = performance.now();
const from = Math.max(0, progress - 0.35);
const tick = (now: number) => {
const k = Math.min(1, (now - t0) / 1800);
setF(from + (progress - from) * (1 - Math.pow(1 - k, 3)));
if (k < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, progress]);
return (
<div ref={ref} data-clip-ok className="relative size-full overflow-hidden bg-surface-2" role="img" aria-label={`${t.depot} → ${t.home}`}>
{/* Below 1024 px the route fills the frame. From 1024 px it moves right, the card stands on the free left side. */}
<div className="absolute inset-0 lg:hidden">
<MapDrawing f={f} t={t} viewBox="70 50 670 380" labelScale={1.7} />
</div>
<div className="absolute inset-0 max-lg:hidden">
<MapDrawing f={f} t={t} viewBox="-420 40 1160 400" />
</div>
</div>
);
}
function MapVariant(props: OrderTrackingProps & { t: Texts }) {
const { t } = props;
const locale = useLocale();
const order = props.order;
const p = order?.parcels[0];
const live = props.live ?? { stopsAway: 3, minutes: 25 };
// The minutes count down while the page is open, once a minute
const [minutes, setMinutes] = useState(live.minutes);
useEffect(() => {
setMinutes(live.minutes);
const id = setInterval(() => setMinutes((m) => Math.max(1, m - 1)), 60_000);
return () => clearInterval(id);
}, [live.minutes]);
if (!order || !p) return null;
const stops = live.stopsAway;
const progress = Math.max(0.2, Math.min(0.94, 1 - stops * 0.07 - 0.12));
const card = (
<div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-6">
<p className="inline-flex items-center gap-2 text-[12px] font-medium text-success-foreground">
<span className="relative flex size-2">
<span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-success" />
</span>
{t.out}
</p>
<p className="mt-3 text-h3 font-semibold tracking-[-0.02em] text-foreground">
<span className="tabular-nums">{fill(t.minutes, { n: minutes })}</span>
</p>
<p className="mt-1 text-[14px] text-foreground">{stops > 1 ? fill(t.stopsAway, { n: stops }) : stops === 1 ? t.oneStop : t.nextStop}</p>
<div className="mt-4 flex gap-1" aria-hidden>
{Array.from({ length: Math.max(1, stops) + 1 }, (_, i) => (
<span key={i} className={cn('h-1.5 flex-1 rounded-full', i === Math.max(1, stops) ? 'bg-primary' : 'bg-track')} />
))}
</div>
{live.driver && (
<div className="mt-5 flex items-center gap-3 border-t border-border pt-4">
<span className="grid size-10 place-items-center rounded-full bg-muted text-[14px] font-semibold text-foreground">{live.driver.slice(0, 1)}</span>
<div className="min-w-0 flex-1">
<p className="text-[12px] text-muted-foreground">{t.driver}</p>
<p className="truncate text-[14px] font-medium text-foreground">{live.driver}</p>
</div>
<Button href={props.links?.help ?? '#'} variant="outline" size="sm" iconLeft="chat">
{t.message}
</Button>
</div>
)}
<div className="mt-5 border-t border-border pt-4 text-[13px] text-muted-foreground">
<p className="flex items-center justify-between gap-3">
<span>{t.order}</span>
<span className="font-medium tabular-nums text-foreground">{order.number}</span>
</p>
{p.eta?.from && p.eta.to && (
<p className="mt-1.5 flex items-center justify-between gap-3">
<span>{t.arrives}</span>
<span className="text-right text-foreground">{fill(t.between, { from: formatTime(p.eta.from, locale), to: formatTime(p.eta.to, locale) })}</span>
</p>
)}
</div>
<p className="mt-4 text-[11px] text-subtle-foreground">{t.liveNote}</p>
</div>
);
return (
<section className={cn('py-10 sm:py-14 lg:py-16', props.className)}>
<div className="kit-container">
{/* Desktop: the card floats over the map's free left side. Below 1024 px it follows the map. */}
<div className="relative overflow-hidden rounded-2xl border border-border aspect-[16/10] max-sm:aspect-[4/3] lg:aspect-auto lg:h-[34rem]">
<RouteMap progress={progress} t={t} />
<div className="absolute bottom-6 left-6 top-6 hidden w-[21rem] flex-col justify-end lg:flex">{card}</div>
</div>
<div className="mt-4 lg:hidden">{card}</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* lookup */
/* ------------------------------------------------------------------ */
function Lookup(props: OrderTrackingProps & { t: Texts }) {
const { t } = props;
const locale = useLocale();
const id = useId();
const [number, setNumber] = useState('');
const [contact, setContact] = useState('');
const [errors, setErrors] = useState<{ number?: string; contact?: string; form?: string }>({});
const [busy, setBusy] = useState(false);
const [found, setFound] = useState<TrackingOrder | null>(null);
const form = useRef<HTMLFormElement>(null);
const result = useRef<HTMLDivElement>(null);
useShake(form, !!(errors.number || errors.contact || errors.form));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const n = number.trim().toUpperCase();
const next: typeof errors = {};
if (!n) next.number = t.eNumber;
else if (!/^[A-Z]{2}-?\d{4,8}$/.test(n)) next.number = t.eNumberFormat;
if (!contact.trim()) next.contact = t.eContact;
setErrors(next);
if (next.number || next.contact) {
requestAnimationFrame(() => form.current?.querySelector<HTMLInputElement>('[aria-invalid="true"]')?.focus());
return;
}
setBusy(true);
let o: TrackingOrder | null | undefined;
if (props.onLookup) o = await props.onLookup(n, contact.trim());
else {
await new Promise((r) => setTimeout(r, 600));
const c = contact.trim().toLowerCase();
o = props.orders?.find((x) => x.number.replace('-', '') === n.replace('-', '') && (x.email?.toLowerCase() === c || x.postcode?.toLowerCase() === c));
}
setBusy(false);
if (!o) return setErrors({ form: t.eNotFound });
setFound(o);
requestAnimationFrame(() => result.current?.focus());
};
const field = 'h-12 w-full rounded-lg border border-input bg-background px-4 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[15px]';
const p = found?.parcels[0];
return (
<section className={cn('kit-glow py-14 sm:py-20 lg:py-24', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mx-auto max-w-[34rem]">
{found && p ? (
<div ref={result} tabIndex={-1} className="animate-[kitshop-zoom_0.5s_var(--ease-kit)] rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] outline-none sm:p-7">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[13px] text-muted-foreground">
{t.order} <span className="font-medium tabular-nums text-foreground">{found.number}</span>
</p>
<div className="mt-3">
<Eta parcel={p} t={t} size="md" />
</div>
</div>
<span className="grid size-12 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-primary">
<Icon name={STAGE_ICON[p.stage]} size={22} />
</span>
</div>
<div className="mt-6">
<Stages stage={p.stage} t={t} compact />
</div>
{p.events[0] && (
<div className="mt-6 rounded-xl bg-surface-2 p-4">
<p className="text-[12px] font-medium text-muted-foreground">{t.latest}</p>
<p className="mt-1 text-[14px] font-medium text-foreground">{p.events[0].text}</p>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{formatDate(locale, p.events[0].date)} · {formatTime(p.events[0].time, locale)}
{p.events[0].place ? ` · ${p.events[0].place}` : ''}
</p>
</div>
)}
<div className="mt-6 flex flex-col gap-2 sm:flex-row">
<Button href={props.links?.help ?? '#'} variant="outline" className="flex-1">
{t.help}
</Button>
<Button variant="ghost" iconLeft="arrow-left" onClick={() => { setFound(null); setNumber(''); setContact(''); }} className="flex-1">
{t.another}
</Button>
</div>
</div>
) : (
<div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<span className="grid size-12 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-primary">
<Icon name="package" size={22} />
</span>
<h2 className="mt-5 text-h3 font-semibold text-foreground">{t.lookupTitle}</h2>
<p className="mt-2 text-pretty text-[15px] text-muted-foreground">{t.lookupText}</p>
<form ref={form} onSubmit={submit} noValidate className="mt-6 space-y-4">
<div>
<label htmlFor={`${id}-n`} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.number}
</label>
<input id={`${id}-n`} value={number} onChange={(e) => { setNumber(e.target.value); setErrors({}); }} placeholder={t.numberHint} autoComplete="off" autoCapitalize="characters" aria-invalid={!!errors.number || undefined} aria-describedby={errors.number ? `${id}-ne` : undefined} className={cn(field, 'uppercase placeholder:normal-case')} />
{errors.number && <p id={`${id}-ne`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.number}</p>}
</div>
<div>
<label htmlFor={`${id}-c`} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.contact}
</label>
<input id={`${id}-c`} value={contact} onChange={(e) => { setContact(e.target.value); setErrors({}); }} autoComplete="email" aria-invalid={!!errors.contact || undefined} aria-describedby={errors.contact ? `${id}-ce` : undefined} className={field} />
{errors.contact && <p id={`${id}-ce`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.contact}</p>}
</div>
{errors.form && (
<p role="alert" className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] gap-2 rounded-lg bg-[color-mix(in_oklab,var(--destructive)_9%,var(--card))] px-3 py-2.5 text-[13px] text-foreground">
<Icon name="alert-circle" size={16} className="mt-0.5 shrink-0 text-destructive" />
{errors.form}
</p>
)}
<Button type="submit" size="lg" disabled={busy} icon={busy ? undefined : 'arrow-right'} className="w-full">
{busy && <Icon name="loader" size={17} className="animate-kit-spin motion-reduce:animate-none" />}
{busy ? t.finding : t.find}
</Button>
</form>
</div>
)}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* parcels */
/* ------------------------------------------------------------------ */
function Parcels(props: OrderTrackingProps & { t: Texts }) {
const { t } = props;
const order = props.order;
const [active, setActive] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const base = useId();
if (!order) return null;
const parcels = order.parcels;
const p = parcels[Math.min(active, parcels.length - 1)];
const onKey = (e: React.KeyboardEvent, i: number) => {
const n = parcels.length;
const go = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
if (go < 0) return;
e.preventDefault();
setActive(go);
tabs.current[go]?.focus();
};
return (
<section className={cn('py-12 sm:py-16 lg:py-20', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mx-auto max-w-[60rem]">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-[13px] text-muted-foreground">
{t.order} <span className="font-medium tabular-nums text-foreground">{order.number}</span>
</p>
<h2 className="mt-1 text-h3 font-semibold text-foreground">{fill(t.parcelsOf, { n: parcels.length })}</h2>
</div>
</div>
<div role="tablist" aria-label={fill(t.parcelsOf, { n: parcels.length })} className="mt-6 grid grid-cols-1 gap-2 sm:grid-cols-3 sm:gap-3">
{parcels.map((x, i) => {
const on = i === active;
const pct = (idx(x.stage) / (STAGES.length - 1)) * 100;
return (
<button
key={x.id}
ref={(el) => { tabs.current[i] = el; }}
role="tab"
id={`${base}-t${i}`}
aria-selected={on}
aria-controls={`${base}-p`}
tabIndex={on ? 0 : -1}
onClick={() => setActive(i)}
onKeyDown={(e) => onKey(e, i)}
className={cn('group/tab rounded-xl border p-4 text-left transition-[border-color,background-color,box-shadow] duration-300', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] shadow-[var(--shadow-sm)]' : 'border-border bg-card hover:border-border-strong')}
>
<span className="flex items-center justify-between gap-3">
<span className="text-[14px] font-semibold text-foreground">{x.label ?? `${t.parcel} ${i + 1}`}</span>
<span className={cn('inline-flex items-center gap-1 text-[12px] font-medium', x.stage === 'delivered' ? 'text-success-foreground' : 'text-muted-foreground')}>
<Icon name={x.stage === 'delivered' ? 'check-circle' : STAGE_ICON[x.stage]} size={13} />
{t[x.stage]}
</span>
</span>
<span className="mt-3 block h-1 overflow-hidden rounded-full bg-track" aria-hidden>
<span className={cn('block h-full rounded-full transition-[width] duration-700 ease-[var(--ease-kit)]', x.stage === 'delivered' ? 'bg-success' : 'bg-primary')} style={{ width: `${Math.max(6, pct)}%` }} />
</span>
{x.items?.length ? (
<span className="mt-3 flex -space-x-2" aria-hidden>
{x.items.slice(0, 4).map((it, k) => (
<ProductMedia key={k} className="size-8 rounded-full ring-2 ring-card">
{it.image}
</ProductMedia>
))}
</span>
) : null}
</button>
);
})}
</div>
<div key={p.id} id={`${base}-p`} role="tabpanel" aria-labelledby={`${base}-t${active}`} tabIndex={0} className="mt-6 animate-[kitshop-fade_0.35s_var(--ease-kit)] rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div className="grid grid-cols-1 gap-6 p-5 sm:p-7 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
<Eta parcel={p} t={t} size="md" />
<p className="flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
{t.tracking} <CopyNumber value={p.trackingNumber} t={t} />
</p>
</div>
<div className="border-t border-border px-5 py-6 sm:px-7">
<Stages stage={p.stage} t={t} />
</div>
<div className="grid grid-cols-1 gap-8 border-t border-border p-5 sm:p-7 lg:grid-cols-[minmax(0,1fr)_17rem]">
<div>
<h3 className="mb-4 text-[15px] font-semibold text-foreground">{t.history}</h3>
<Events events={p.events} t={t} initial={3} />
</div>
{p.items?.length ? (
<div className="lg:border-l lg:border-border lg:pl-8">
<h3 className="mb-4 text-[15px] font-semibold text-foreground">{t.inside}</h3>
<ul className="space-y-3">
{p.items.map((it, k) => (
<li key={k} className="flex items-center gap-3">
<ProductMedia className="size-14 shrink-0 rounded-lg">{it.image}</ProductMedia>
<div className="min-w-0">
<p className="truncate text-[14px] font-medium text-foreground">{it.name}</p>
<p className="text-[12px] text-muted-foreground">
{it.variant}
{it.quantity && it.quantity > 1 ? ` · ${it.quantity}×` : ''}
</p>
</div>
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
</div>
</div>
</section>
);
}
/**
* Order tracking after the purchase: a timeline with every scan, a drawn
* map with the van on its way, a lookup for guests and an order split into
* parcels. Five stages from ordered to delivered.
*/
export function OrderTracking(props: OrderTrackingProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'timeline' } = props;
if (variant === 'map') return <MapVariant {...props} t={t} />;
if (variant === 'lookup') return <Lookup {...props} t={t} />;
if (variant === 'parcels') return <Parcels {...props} t={t} />;
return <Timeline {...props} t={t} />;
}Frequent questions
How do I install Order tracking?
With aiw add @ai-website/order-tracking. The command puts components/ui/order-tracking.tsx into your project, the code is yours from then on. It brings along button, icon, product-image and input. It needs no further npm packages.
Which variants does Order tracking have?
4 variants, Timeline, Live map, Guest lookup and Several parcels. You pick one with the variant prop, for example variant="map".
Can I translate the texts of Order tracking?
Yes. Order tracking carries 49 texts of its own in English and German, such as “Ordered”, “Packed” and “On its way”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Order tracking?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Order tracking with aiw_components and install it with aiw_add @ai-website/order-tracking. Name the variant in your prompt, for example variant="timeline", and the assistant writes your content straight into the props.