Gauge Arc

Half a ring divided into segments, drawn one after another, with the share of whichever one is in focus in the middle.

Loading preview

Installation

npx shadcn@latest add @loomui/gauge-arc

Every chart in this section shares one card, chart-frame, which the CLI pulls in for you.

Usage

import { ChartRange } from "@/components/ui/chart-frame"
import { GaugeArc } from "@/components/ui/gauge-arc"
<GaugeArc
  label="Sessions by device"
  delta={5.2}
  segments={[
    { name: "Desktop", value: 1180 },
    { name: "Mobile", value: 620 },
    { name: "Tablet", value: 380 },
    { name: "TV", value: 320 },
  ]}
  range={<ChartRange>Last 7 days</ChartRange>}
/>

Segments run clockwise from the left. The middle reads the first one until you point at another.

When not to use it

A gauge earns its place when one share is the headline and the rest are context. If all four segments matter equally, this is a worse bar chart: arcs at different positions on a curve are genuinely hard to compare against each other, which is exactly what a row of bars is good at.

That is also why the numbers sit on tiles underneath rather than being left to be read off the ring. The ring shows which one is biggest; the tiles say by how much.

How it works

The gap comes out of the segment

Each segment is given its share of 180 degrees, and then loses half the gap at each end.

return { from: from - gap / 2, to: angle + gap / 2 }

Adding gaps between segments instead would push the total past a half turn, and the more segments you passed the further round it would go.

One hand goes round

The segments are not drawn. They are already there, and a single arc sweeps across them inside a <mask>, uncovering each as it passes.

<mask id={id} maskUnits="userSpaceOnUse" x="0" y="0" width={W} height={H}>
  <rect width={W} height={H} fill="black" />
  <path
    d={arc(180, 0)}
    stroke="white"
    pathLength={1}
    strokeDasharray={1}
    style={{ strokeDashoffset: arrived ? 0 : 1 }}
  />
</mask>

Drawing each segment on its own dash was the first attempt, and it was four animations that had to be talked into looking like one. Every seam between them was a hitch. It also grew each segment out of a dot, because a round cap at zero length is a dot, so four of those appeared and swelled in turn. Giving every segment the same duration made it worse in a different way: a 10% slice crawled while a 52% slice raced.

One animation cannot fall out of step with itself, and the caps arrive already round because they are uncovered rather than drawn.

maskUnits and the bounds are stated rather than left to default. A mask defaults to a region measured off the masked object's bounding box, inset to -10%/120% of it. The ring's caps and the wider sweep path both fall outside that box, so the ring came back with its ends shaved and, on the first frame, one segment showing through on its own before the sweep had started.

The track is not masked. It is the dial the colour fills, so it has to be there before the filling starts.

The hit target is not the ring

A 17 unit stroke is a 17 unit hit target, which is a thin thing to ask someone to point at. Each segment carries a second copy of its own path, wider and fully transparent, with pointer-events: stroke.

<path
  stroke="transparent"
  strokeWidth={thickness + 14}
  style={{ pointerEvents: "stroke" }}
/>

Nothing is drawn by it. It is only pointed at.

The middle is HTML

Like the axis labels on the other charts. Text inside a viewBox scales with the container, so the percentage would be one size in a dashboard column and another across a full-width panel.

Pointing at a legend tile moves the middle too: the tiles carry onTileFocus, so hovering a name is the same gesture as hovering its arc.

Props

GaugeArc takes everything a figure takes, plus:

PropTypeDefaultDescription
segmentsGaugeSegment[]requiredClockwise from the left.
labelstring"Total"What the headline counts.
deltanumbernonePercentage change on the headline.
rangeReactNodenoneThe range control.
durationnumber820Milliseconds for the ring to fill.
gapnumber5Surface left between segments, in plot units.
thicknessnumber18Ring thickness, in the plot's own units.
startOnViewbooleantrueHold until the gauge is on screen.
disabledbooleanfalseRender the finished ring with no draw.
format(value: number) => stringgroupedFormats the headline and the tiles.

GaugeSegment is { name, value, color? }.

Reduced motion

With reduced motion the ring is already whole on the first frame. Pointing at a segment still works.