Skip to content

Track

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/track.json
See it liveA working day as spans, and a set of tiers with a score standing among them.→

Two products had written this independently and arrived at the same construction — a rounded track, segments positioned absolutely by percent, a floor under the segment width so a short one does not vanish — differing only in what a segment meant. One drew the tiers of a rubric with the score standing among them; the other drew a working day as alternating work and breaks.

Each knew something the other did not. The tiers had the marker and the three-state reading of a band — passed, standing in, still ahead. The day had the minimum width, and the difference between an empty track and an unknown one. So it is one component with two shapes:

spans — stretches of a whole, each meaning something in its own right. Work and breaks, phases, occupancy. Gaps are the track showing through, and a gap in the data has to look different from a boundary between two stretches, which is why divided is off here.

<Track
segments={bands.map((band, i) => ({
key: String(i),
start: band.from,
end: band.to,
tone: band.paused ? 'idle' : 'accent',
label: band.label,
}))}
from={dayStart}
to={dayEnd}
label="Worked 7h 15m, one break of 45m"
/>

thresholds — a scale cut into bands, with a position on it. The point is not the bands but where you stand among them: nearly a clip is what the reader wants, and a badge naming the band cannot say it. Here the bands touch, so divided draws a hairline of the ground between them.

<Track
segments={tiers.map((tier, i) => ({
key: tier.key,
start: tier.min,
end: tiers[i + 1]?.min ?? 100,
tone: score >= tier.min ? (standingIn(tier) ? 'accent' : 'past') : 'idle',
label: `${tier.label} · ${tier.min}+`,
}))}
from={0}
to={100}
marker={score}
label={`Score ${score}, in ${current.label}`}
divided
/>
<TrackScale>
{tiers.map((tier) => <span key={tier.key}>{tier.label}</span>)}
</TrackScale>

A sliver is widened to something visible, and the rest of the bar gives way for it. A twelve-second break in an eight-hour day is 0.04% of the track — real, measured, and rounding to no pixels at all. The floor draws it at 0.6% and pushes what follows along; the debt that creates is paid back by the segments wide enough to afford it, so the bar still ends exactly at its own edge. A widened segment says so in widened, so a caller can label it under a minute rather than a duration the bar is no longer drawing to scale.

The obvious way to do this is wrong, and it was the first thing tried here: capping each sliver at where the next segment starts protects against overlap, and also means a segment touching its neighbour can never grow at all. Both shapes this component exists for are contiguous — work, break, work; one tier after another — so the floor did nothing for either of them, and only a look at the stand showed it.

The scale is stated, not inferred. from and to are what the two donors disagreed about, and both were right: a working day is read against itself — an eight-hour day drawn across a third of the width wastes the space where the breaks are — while tiers are read against the whole 0–100 scale, where the distance to the next one is the distance you have to close. The marker rides the same bounds, so a tier at 78 and a score of 78 land in the same place.

A marker outside the track is absent, not pinned to the edge. Clamped, it would say here, at the very end, which is a different statement from not on this track at all.

The track owns its geometry in its own box, never a percentage of a parent’s. That is the failure that cost a consumer a patch release: a percentage-height chart inside a flex row resolved every bar to zero, the graph came out empty, and neither the tests nor the API check could see it — the owner found it by looking.

The height is a variant, not a fixed value. It is sm in one donor and md in the other because the bar carries different weight on the two screens.

label is required, because the segments announce nothing: their title is a hover affordance, and a screen reader is given the bar as one image. Say what the whole bar says.

Segment tones. accent is the subject: work done, the band you are standing in. past is behind you - dimmed, so the current band carries the eye. idle is ahead, or not the subject: a break, a band not yet reached. good, warn and bad mark a stretch that is itself a state rather than a quantity.

The arithmetic is importable without React. place(segments, { from, to, minWidth }) from track-segments returns { left, width, widened } per segment, and markerAt(value, from, to) returns a percentage or null.

Props

Read from the types in ui/track.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

Track

PropTypeDefault
sizesm | md'md'
segmentsrequiredTrackSegment[]The stretches to draw: each has a key, a start and an end, and optionally a tone and a label.
fromnumberThe value the left edge stands for. Defaults to the earliest segment.
tonumberThe value the right edge stands for. Defaults to the latest segment.
markernumberWhere the position marker stands, on the same scale as the segments. Omitted when there is no such thing - a day of work has no "you are here".
labelrequiredstringWhat the whole bar says, for a reader who cannot see it. Required: the segments are decoration to a screen reader, and their titles are not announced in order.
minWidthnumberOverride the floor under a segment's width - 0 draws everything exactly to scale.
dividedbooleanfalseSeparate adjacent segments with a hairline of the ground.

And every prop of React's HTMLAttributes<HTMLDivElement> except children, passed on.

TrackScale

PropTypeDefault
childrenrequiredReactNode

And every prop of React's HTMLAttributes<HTMLDivElement>, passed on.

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 15 rules applied and passed, and none failed. The accessibility report has every item.

History

Each release that changed ui/track.tsx - the file a product copies - newest first.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.33.0breaking
    • feat no-raw-button and no-arbitrary-scale ac7a11a
  3. v0.28.0
    • feat measure in steps, and name the step that was missing 8dd8a28
  4. v0.24.0
    • feat a bar divided into stretches, with something standing along it cd91226