BarTicker
BarTicker is a dense strip of bars, one per time bucket, in a single SVG. It shows when something spiked, and how bad the worst bucket was, in the footprint of one card. It never sees a Date. The parent buckets the data, picks the granularity, and owns every label.
import BarTicker from "@haloid/azucar/ui/bar-ticker";
Data and sizing
The component is generic over its datum. valueAccessor reads the magnitude and keyAccessor gives each bucket a stable key. There are no width or height props: the root fills its parent and measures itself with react-use-measure, so the parent must have a real size. Every geometry value derives from that measurement, including slot width, bar width, dot size, and the pill radius. A min-h-16 floor lives inside the component, because legibility below 64px is a property of the visualization, not of a call site.
Thirty daily counts with one spike. The demo wrapper is h-20 w-120. In the product the card and the page grid do the sizing.
<div className="h-20 w-120"> <BarTicker data={[0, 2, 1, 0, 0, 3, 1, 0, 4, 2, 0, 0, 1, 5, 2, 1, 0, 0, 2, 12, 7, 3, 1, 0, 2, 1, 0, 3, 1, 2].map( (count, day) => ({ count, day }), )} valueAccessor={(bucket) => bucket.count} keyAccessor={(bucket) => String(bucket.day)} ariaLabel="Data issues found per day over the last 30 days" /> </div>
Scale
The default scale, max-dominant, distorts on purpose. A bucket equal to the maximum fills the full height. Every other nonzero bucket renders at ratio * height * intermediateCap, so with the default cap of 0.6 it can never pass 60 percent of the height. The worst bucket dominates, because that is the question the strip answers. scale="linear" is the undistorted alternative: it drops the cap and shows true ratios. intermediateCap has no effect under linear.
The same ramp under both scales. linear draws the pyramid. max-dominant flattens everything but the peak.
<div className="flex w-120 flex-col gap-4"> {["max-dominant", "linear"].map((scale) => ( <div key={scale}> <p className="mb-1 text-[12px] text-fg-2">{scale}</p> <div className="h-20"> <BarTicker scale={scale} data={[3, 6, 9, 12, 15, 18, 21, 24, 21, 18, 15, 12, 9, 6, 3].map( (count, step) => ({ count, step }), )} valueAccessor={(bucket) => bucket.count} keyAccessor={(bucket) => String(bucket.step)} ariaLabel={"Scale comparison ramp, " + scale} /> </div> </div> ))} </div>
The dot floor
Nothing renders shorter than the floor. With the default minBarHeight="dot", the floor equals the bar width, and the pill radius turns a floored bar into a circle. Zero buckets and small nonzero buckets share that dot size. Color alone separates them: zero is faint, nonzero starts darker. An all-zero series renders as a row of faint dots, which reads as a calm baseline, not an error.
Here the ones and twos sit at the floor next to the zeros. Look at the opacity, not the height.
<div className="h-20 w-120"> <BarTicker data={[0, 1, 0, 0, 2, 0, 1, 0, 0, 0, 12, 1, 0, 2, 0, 0, 1, 0, 0, 0].map( (count, day) => ({ count, day }), )} valueAccessor={(bucket) => bucket.count} keyAccessor={(bucket) => String(bucket.day)} ariaLabel="Sparse counts collapsing to the dot floor" /> </div>
A numeric minBarHeight replaces the width-based floor. A numeric radius removes the circle effect, which is an accepted trade at that call site.
Marker dots
dotAccessor adds a second channel: a marker dot above the bar, in dotColor. A marked bar gives up ceil(slot) + 2 pixels of height to make room, and the dot sits above it with a 2px gap. A marked zero bucket shows only the dot. No product surface uses this channel yet.
<div className="h-20 w-120"> <BarTicker data={[2, 0, 1, 3, 0, 0, 2, 5, 1, 0, 2, 0, 0, 1, 4, 2, 0, 1, 0, 2].map( (count, day) => ({ count, day, audit: day === 4 || day === 7 }), )} valueAccessor={(bucket) => bucket.count} keyAccessor={(bucket) => String(bucket.day)} dotAccessor={(bucket) => bucket.audit} ariaLabel="Counts per day with audit days marked" /> </div>
Color
The default barColor is a monochrome ramp on --color-fg-4 through color-mix. Zero buckets render at 15 percent strength. Nonzero buckets start at 30 percent and reach full strength at the maximum:
// the defaultconst opacity = value === 0 ? 15 : 30 + ratio * 70;return `color-mix(in srgb, var(--color-fg-4) ${opacity}%, transparent)`;
The override receives { datum, value, ratio } and returns any CSS color. Anything more complex than the default ramp stays a one-line function at the call site, not a new prop.
Hover
Each column owns a transparent hit rect that spans the full chart height, so dot-only columns are easy to target. While a column is hovered, a full-height strip appears behind its bar, unless highlightOnHover is off. onHover fires with the datum and index on enter, and with null, null on leave. The component has no internal tooltip. The parent turns the hover into text it already owns.
function HoverReadout() { const [hovered, setHovered] = useState(null); return ( <div className="flex w-120 flex-col gap-2"> <p className="text-[14px] text-fg-2"> {hovered ? hovered.count + " on day " + (hovered.day + 1) : "Hover a column"} </p> <div className="h-20"> <BarTicker data={[0, 2, 1, 0, 0, 3, 1, 0, 4, 2, 0, 0, 1, 5, 2, 1, 0, 0, 2, 12, 7, 3, 1, 0, 2, 1, 0, 3, 1, 2].map( (count, day) => ({ count, day }), )} valueAccessor={(bucket) => bucket.count} keyAccessor={(bucket) => String(bucket.day)} onHover={(bucket) => setHovered(bucket)} ariaLabel="Data issues per day with a hover readout above" /> </div> </div> ); } render(<HoverReadout />);
The dynamic card title demo on the Card page shows the full pattern: the card header becomes the readout, and returns to the total on leave.
The bars carry the component's only motion: a 300ms ease-out transition on height, position, and fill, so a live value change grows and re-colors in place. There is no mount animation. The hover strip applies instantly, and reduced motion disables the transition.
In the product
The discrepancy history card on the reconciliation overview is the first consumer. Daily buckets over 30 days, the default scale and colors, and a header that follows the hover. Source: apps/north/src/components/pages/reconciliation-overview/discrepancy-history-card.tsx.
const [hovered, setHovered] = useState<DiscrepancyBucket | null>(null);<div className="h-20"><BarTickerdata={buckets}valueAccessor={(bucket) => bucket.count}keyAccessor={(bucket) => bucket.date.toISOString()}onHover={(bucket) => setHovered(bucket)}ariaLabel="Discrepancies found per day over the last 30 days"/></div>
API
interface BarTickerProps<Datum> {data: readonly Datum[];valueAccessor: (datum: Datum) => number;keyAccessor: (datum: Datum) => string;barColor?: (info: { datum: Datum; value: number; ratio: number }) => string;// default: fg-4 ramp, 15% at zero, 30..100% by ratioradius?: number | "pill"; // default "pill" (rx = barWidth / 2)gap?: number; // px between bars, default 2minBarHeight?: "dot" | number; // default "dot" (floor = bar width)scale?: "max-dominant" | "linear"; // default "max-dominant"intermediateCap?: number; // default 0.6, max-dominant onlydotAccessor?: (datum: Datum) => boolean; // marker dot above the bardotColor?: string; // default var(--color-fg-4)onHover?: (datum: Datum | null, index: number | null) => void;highlightOnHover?: boolean; // default trueariaLabel: string; // required}
Known gaps: the SVG is one role="img" labeled by ariaLabel, so assistive tech gets one sentence and no per-bucket values. Hover is the only input. There is no keyboard or touch path to onHover. Time logic, tooltips, and axis labels do not exist by design. The parent owns all of them.