When I started building Timespace in January 2024, I expected time zones to be
the hard part.
I was wrong. Time zones were difficult, but the problem that took the most
thought was more visual: how do you stop several absolutely positioned DOM
labels from covering one another while the user is dragging them?
Timespace puts several local days on parallel timelines. Every horizontal
position represents the same moment, so a user can drag one interval and see
what it means in New York, London, Bangkok, or anywhere else.
The hour grid was easy. The labels were trouble.
There is a clock attached to the live "now" line, another clock on every
interval endpoint, and a time-zone name at the edge of each row. Every label
can sit on the left or right of its anchor. Its width changes with the font,
theme, time format, and whether seconds are visible.
Near the right edge, a clock can leave the container. Two nearby endpoints can
cover each other. The current-time clock can run into an interval or the row
name. Everything moves during a drag, and every pixel becomes stale after a
resize.
CSS could position the elements, but it could not make the decision I needed:
Try the other side of the anchor. Keep it there only if it fits and does not
hit another label. If neither side works, move it into a vertical lane.
That is how a timeline widget ended up with a small collision engine.
Time is the value; pixels are only the current view
The first thing I needed was a stable coordinate model.
A day contains 86,400 seconds. The timeline has a width measured from the real
DOM. The conversion is linear:
const SECONDS_IN_DAY = 24 * 60 * 60;
function secondsToX(seconds, width, left) {
return (seconds / SECONDS_IN_DAY) * width + left;
}
function xToSeconds(x, width, left) {
return ((x - left) / width) * SECONDS_IN_DAY;
}
Each interval stores two representations:
-
xPos1DayOffsetSecondsandxPos2DayOffsetSecondsdescribe the time; -
xPos1andxPos2describe where to render it right now.
When the widget changes width, it regenerates the pixel positions from the
seconds. A point at 14:00 stays at 14:00 instead of staying at an old
x-coordinate.
The production code also deals with two different offsets: the hour strip
inside the component and the component inside the viewport. This detail caused
some painful early bugs. CSS positions are local to the component, while
pointer events report viewport coordinates. Collision detection only works
when all of its inputs use the same coordinate space.
The conversion helpers are in
core/timeLineMath.js.
Measuring instead of guessing
I did not want to estimate clock widths from character counts. Different
fonts, themes, and time formats make that unreliable.
The widget renders sample labels and measures:
- the width and viewport position of the hour strip;
- the natural width of an interval clock;
- the natural width of the live clock;
- the widest row header;
- the height available for labels.
A targeted ResizeObserver repeats the measurement when the container changes
size. There is also a short-lived MutationObserver for clock elements that
are empty on the first render and receive text just after mounting.
The collision resolver itself never queries the DOM. It receives those
measurements as numbers and returns layout data. This keeps the geometry
deterministic enough to test without mounting React.
The measurement hook is
useTimeLineMeasurements.js.
Turning labels into horizontal intervals
For collision purposes, the important information is horizontal: the anchor
position, the label width, and the side on which it is rendered.
I reduce each label to a one-dimensional interval:
function boundary(anchor, width, side) {
return side === "left"
? { start: anchor - width, end: anchor }
: { start: anchor, end: anchor + width };
}
function overlaps(first, second) {
return first.end > second.start && first.start < second.end;
}
Why one dimension when DOM elements are two-dimensional?
Horizontal position represents time, so it should be preserved whenever
possible. Vertical movement has no meaning in the model. It is only a fallback
when a horizontal conflict cannot be removed.
The resolver starts with a preferred side: right. Keeping one default stops
labels from moving without a reason.
For each movable label, it does roughly this:
- Build its boundary on the current side.
- Use the preferred side if it fits.
- If it crosses a timeline boundary, try the other side.
- If it overlaps another item, try the other side again.
- Accept that switch only when it stays inside the timeline and does not create a new collision.
Moving a label to its other side is just translating it by its measured width:
function moveToOppositeSide(label, width, leftEdge, rightEdge) {
if (label.side === "left") {
const start = label.start + width;
const end = label.end + width;
return end <= rightEdge
? { ...label, side: "right", start, end }
: label;
}
const start = label.start - width;
const end = label.end - width;
return start >= leftEdge
? { ...label, side: "left", start, end }
: label;
}
The time-zone header is an obstacle too, but its side is chosen from the live
time position. As the "now" line approaches the header, the header moves to the
other end of the row.
I did not try to build a solver that flips labels repeatedly until it finds a
global optimum. During a drag, predictable output is more useful than a
slightly tighter layout that may jump between frames.
The complete resolver is in
core/timeLineCollision.js.
When neither side is free
Some collisions cannot be fixed horizontally. Several endpoints can represent
the same time, or a label can be trapped between the live clock and the row
header.
The remaining horizontal bounds form an interval graph. I sort them by start
position and assign vertical lanes:
for each label from left to right:
use the first lane whose previous label has already ended
if no lane is free, create a new lane
A lane can be reused as soon as its previous end is less than or equal to the
next start.
There is one extra stability rule. Consider this chain:
A overlaps B
B overlaps C
A does not overlap C
All three belong to one connected horizontal group. I use one stack size for
the group instead of calculating font size from each label's direct collision
count. Otherwise B can become smaller than A and C even though they are all
part of the same visual collision.
The resolver returns the chosen side, overlapping indexes, lane number, and
total lanes in the connected group.
A React hook turns that result into top, fontSize, scale, and zIndex.
It also checks whether anything actually changed before updating state. That
guard matters because a render-measure-resolve loop can easily trigger itself
forever.
A feedback loop I did not expect
One of the stranger bugs came from measuring a time-zone name after shrinking
it.
The loop was:
- Measure a wide header.
- Detect a collision and reduce its font.
- Measure the now-narrow header.
- Decide the collision is gone and restore the font.
- Measure the wide header again.
The layout could oscillate without any user interaction.
The fix was to separate measured geometry from visual scale. The resolver
always uses the header's natural width. When the header needs to shrink, the
renderer uses a CSS transform. Its appearance changes, but the measurement
that produced the collision decision stays stable.
Dragging outside the timeline
My first drag implementation depended too much on events from the timeline
element. It worked until the pointer left the list or the release happened
outside it.
The current version starts with pointerdown on an invisible 16-pixel grab
strip, then installs pointermove and pointerup listeners on window.
This means:
- the drag continues outside the component;
- mouse, touch, and pen use the same event path;
-
pointercanceland window blur end the session safely; - Escape restores the interval captured at the start of the drag.
Pointer moves are coalesced with requestAnimationFrame. The latest position
wins, and no more than one geometry and collision pass runs per frame.
The interaction code is in
useTimeIntervalDrag.js.
State management: two different speeds
Timespace uses a plain React context and reducer rather than a separate state
library.
Timelines and intervals are normalized into ID lists and maps:
timeLinesIds + timeLinesMap
timeIntervalsIds + timeIntervalsMap
The maps make individual updates simple. Memoized arrays provide ordered
collections for rendering. Public actions such as setTimelines,
updateTimeline, and addTimeInterval keep host applications away from the
reducer internals.
The more important decision was splitting the provider into two contexts:
data context: timelines, intervals, settings, dispatch
clock context: current time, zoned clocks, day progress
Those values change at different speeds. Structural data changes occasionally.
The clock changes continuously.
The ticker schedules itself on the next exact interval boundary. After every
tick it reads Date.now() again, so a stalled main thread does not accumulate
timer drift.
Even a separate clock context would rerender its consumers every second. The
current version uses two small synchronization components instead:
- one writes the current x-position into a CSS custom property;
- another caches the clock DOM elements and updates their text.
React still owns the structure and rebuilds the cache when rows change. Direct
DOM writes are limited to isolated, high-frequency presentation data. The 24
hour cells in every row do not need to rerender because 11:26 became 11:27.
The provider is in
state/timeZonesProvider.jsx.
This boundary could still improve. During a drag, a local draft interval could
be rendered without committing each frame to context, followed by one update
on pointer-up.
The other hard problem: time zones
A JavaScript Date represents an instant. It does not carry an IANA time zone
such as America/New_York.
The provider uses cached Intl.DateTimeFormat instances and
formatToParts() to produce clock text and abbreviations. For numeric offsets,
it requests timeZoneName: "longOffset" and parses values such as
GMT+07:00.
The offset is evaluated for an actual date. Berlin is not treated as
permanently UTC+1, and half-hour or quarter-hour zones are not rounded to a
whole hour.
Availability highlighting also works from actual instants. To put a local
08:00–17:00 window onto the displayed home day, it:
- Finds the instant corresponding to the start of the home-zone day.
- Samples each minute across the visual 24-hour range.
- Formats that instant in each row's IANA zone.
- Checks whether the local minute is inside that row's availability.
- Intersects the configured rows to find the time available to everyone.
There is an old workaround I would not use in a new project. The home-clock
path formats a date in the chosen zone and parses that formatted string back
into Date. Locale-dependent parsing is brittle, and it mixes the concepts of
an instant and a wall-clock representation.
JavaScript now has a much better model:
const instant = Temporal.Now.instant();
const bangkok = instant.toZonedDateTimeISO("Asia/Bangkok");
bangkok.hour;
bangkok.offset; // "+07:00"
const homeStart = Temporal.Now
.zonedDateTimeISO("America/New_York")
.startOfDay();
Temporal is now Stage 4 and available in current Firefox, Chrome, and Node,
but it is not yet universal across major browsers. A library still needs to
choose between requiring newer engines, including a polyfill, or retaining an
Intl fallback.
Replacing the remaining formatted-string workaround is one of the next things
I want to do.
What I would improve next
The biggest missing piece is accessibility. The interval handles need keyboard
control, focus management, and better ARIA descriptions.
Other useful improvements would be:
- keep drag state local and commit it on pointer-up;
- add property-based tests for the collision resolver;
- add more tests around DST boundaries;
- progressively adopt
Temporalas browser support improves.
The current pure geometry tests are in
core/__tests__.
The full source is
on GitHub, and the
interactive demo does not require an
account.

Top comments (0)