GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Chart UI Design: 9 Interactions You Can Drag

Chart UI design earns its keep once a reader can put a hand on it. All nine run on CSS bars and inline SVG with no charting library, and in one you drag the

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows how someone actually reads a chart. You check a single value first (01), narrow the series you care about (02), then sweep a period by hand (03). Next comes digging in: open one month into its days (04), swap to a prepared range (05), and flip the same numbers between stacked and grouped (06). The last three are about deciding: move the target yourself and watch the hit count (07), pin two moments and read the gap (08), and pull one table row open to study a single channel (09). One promise ties them together. Everything visible lives in a registered custom property plus an aria-* or data-* attribute, and JavaScript only edits those values while CSS does the drawing. That is what keeps the autoplay preview and a real drag showing the same number. A table ui design sitting under the chart follows the same rule, which is why row 09 can open without keeping a second copy of the data. The window-opening half of the same dashboard is Command Palette UI Design, and drawing the graphs themselves is CSS Chart Design.

01Crosshair that snaps to the nearest point

What you see

Move the pointer across the line and the vertical rule refuses to stand between two days. One rounded integer is the whole snap; CSS reads that integer and places the rule, the ring, and the tooltip.

Crosshair that snaps to the nearest point
Open in a new tab

01 · Crosshair that snaps to the nearest point — Measured area 4.833% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertypointermovearia-live

Where to look in the code

Across the 24-frame render, 23 frames carry motion and the swept area is 4.833%.

var N = 12;
function snap(i) {
  cur = Math.min(Math.max(i, 0), N - 1);
  ch.classList.remove('is-demo');
  ch.style.setProperty('--i', String(cur));
  ch.style.setProperty('--y', ys[cur] + 'px');
  tip.textContent = nums[cur] + 'visits';
  val.textContent = (cur + 1) + ' days · ' + nums[cur] + 'visits';
}
hot.addEventListener('pointermove', function (e) {
  var r = hot.getBoundingClientRect();
  var t = Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1);
  snap(Math.round(t * (N - 1)));
});

02Turning a legend off regrows the other bars

What a dashboard chart ui design does when a legend goes off

Press a legend chip and that series folds to the baseline, then the remaining maximum is recomputed and every remaining bar grows taller.

Turning a legend off regrows the other bars
Open in a new tab

02 · Turning a legend off regrows the other bars — Measured area 8.599% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressed@propertytransform-origin

How to read the code

Because the axis is being re-measured rather than a series being hidden, the card header reports the axis maximum instead of a total, dropping from 460 to 260.

function apply() {
  lg.classList.remove('is-demo');
  var on = [], max = 0;
  for (var k = 0; k < 3; k++) if (chips[k].getAttribute('aria-pressed') === 'true') on.push(k);
  on.forEach(function (k) { V[k].forEach(function (v) { if (v > max) max = v; }); });
  for (var i = 0; i < bars.length; i++) {
    var b = bars[i], s = +b.getAttribute('data-s');
    if (on.indexOf(s) < 0) { b.setAttribute('data-fold', '1'); }
    else {
      b.removeAttribute('data-fold');
      b.style.setProperty('--h', (max ? V[s][+b.getAttribute('data-m')] / max * 100 : 0).toFixed(1) + '%');
    }
  }
  lg.style.setProperty('--mx', String(max));
  live.textContent = 'Axis max ' + max + 'visits';
}

03Drag a range and the summary recounts

What moves

Sweep sideways across 14 bars and the span you swept stays bright while everything outside sinks, with the total and the day count above recounting before you let go.

Drag a range and the summary recounts
Open in a new tab

03 · Drag a range and the summary recounts — Measured area 19.367% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdownsetPointerCapturearia-live

What to change

The bright band is not absolutely positioned: it is one cell wide, pushed by the start index and stretched by the number of cells, so the formula holds at any pixel width.

.br__band {
  display: block;
  height: 100%;
  width: calc(100% / $cell);
  border-radius: $r-xs;
  background: rgba(47, 109, 246, .16);
  transform-origin: left center;
  transform: translateX(calc(var(--s) * 100%)) scaleX(calc(var(--e) - var(--s) + 1));
}

04A month bar splits into days where it stands

What you see

Clicking a month bar swaps nothing out; the bar opens into five daily bars right where it stood.

A month bar splits into days where it stands
Open in a new tab

04 · A month bar splits into days where it stands — Measured area 17.915% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-expandedtransform-originanimation-fill-mode

Where to look in the code

Those five sit stacked in the same cell from the start, and once the month bar steps aside each one slides to its own slot, because var(--i) inside the keyframe resolves per element and a single rule sends all five to different places.

.dd.is-demo .dd__col[data-m="#{$demo-month}"] .dd__day {
  animation-name: dd-day;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: calc(var(--i) * 40ms);
  animation-fill-mode: backwards;
}
@keyframes dd-day {
  0%, 14% { transform: translateX(0) scaleX(1); }
  36%, 58% { transform: translateX(calc((var(--i) - 2) * 30%)) scaleX(.3); }
  78%, 100% { transform: translateX(0) scaleX(1); }
}

057, 30 or 90 days reshapes the bars

What happens on screen

Changing the range keeps all eight bars; only their heights flow to the new values.

7, 30 or 90 days reshapes the bars
Open in a new tab

05 · 7, 30 or 90 days reshapes the bars — Measured area 6.915% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-selected@propertygrid-template-columns

How to read the code

JavaScript edits data-period and aria-selected and stops there; the eight heights, the underline position, and the average and peak figures all branch from those two attributes.

.pg[data-period="7"]  { --seg: 0; --avg: 343; --max: 430; }
.pg[data-period="30"] { --seg: 1; --avg: 293; --max: 372; }
.pg[data-period="90"] { --seg: 2; --avg: 253; --max: 310; }

@each $p, $d in (7: $d7, 30: $d30, 90: $d90) {
  @for $i from 1 through 8 {
    .pg[data-period="#{$p}"] .pg__bar:nth-child(#{$i}) { --h: #{nth($d, $i)}; }
  }
}

06Stacked and grouped show the same numbers twice

What moves

One toggle splits the same data between stacked bars and side-by-side bars.

Stacked and grouped show the same numbers twice
Open in a new tab

06 · Stacked and grouped show the same numbers twice — Measured area 9.367% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedgrid-columnsteps(1, end)

What to change

The previous-month series steps aside horizontally before it settles down, and reverses that order on the way back, because interpolating both axes at once drives the black bar diagonally through the blue one and leaves a frame where the two overlap.

@keyframes sk-b#{$i} {
  0%, 34%   { transform: translateX(0) translateY(#{-1 * nth($ha, $i)}) scaleX(1); }
  44%       { transform: translateX(53%) translateY(#{-1 * nth($ha, $i)}) scaleX(.44); }
  52%, 76%  { transform: translateX(53%) translateY(0) scaleX(.44); }
  82%       { transform: translateX(53%) translateY(#{-1 * nth($ha, $i)}) scaleX(.44); }
  86%, 100% { transform: translateX(0) translateY(#{-1 * nth($ha, $i)}) scaleX(1); }
}

07Drag the goal line and only the bars above change color

What you see

Pulling the horizontal target up or down recolors only the bars that clear it, and the count beside it keeps up before your finger lifts. The drag calls setPointerCapture so it survives the pointer leaving the chart, and the handle carries role="slider", which means the arrow keys move the same value.

Drag the goal line and only the bars above change color
Open in a new tab

07 · Drag the goal line and only the bars above change color — Measured area 18.649% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

setPointerCapture@propertyaria-valuenow

Where to look in the code

Of the 24 frames, 22 carry motion at an intensity of 151.0.

function setGoal(v) {
  v = Math.max(MIN, Math.min(MAX, Math.round(v)));
  var n = 0;
  bars.forEach(function (b, i) {
    var over = DATA[i] >= v ? '1' : '0';
    b.dataset.over = over;
    if (over === '1') { n += 1; }
  });
  root.style.setProperty('--goal', v / 4 + '%');
  root.style.setProperty('--gv', String(v));
  root.style.setProperty('--n', String(n));
  grab.setAttribute('aria-valuenow', String(v));
  grab.setAttribute('aria-valuetext', v + 'visits');
}

08Pin two points and the gap appears

What happens on screen

The first click pins a baseline, the second draws a dashed span with the difference, and a third moves the baseline to that point.

Pin two points and the gap appears
Open in a new tab

08 · Pin two points and the gap appears — Measured area 4.678% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedstroke-dasharrayaria-live

How to read the code

Week 1 at 128 against week 8 at 262 reads +104.7%, and stepping back from week 8 to week 5 at 233 reads −11.1%, because the badge recomputes from the table values every time.

  put('--mx', (xs[a] + xs[b]) / 2 + 'px');
  put('--my', (ys[a] + ys[b]) / 2 + 'px');
  line.setAttribute('x2', xs[b] - xs[a]);
  line.setAttribute('y2', ys[b] - ys[a]);
  var d = (vs[b] - vs[a]) / vs[a] * 100;
  var pct = (d >= 0 ? '+' : '−') + Math.abs(d).toFixed(1) + '%';
  badge.textContent = pct;
  root.setAttribute('data-b', '1');

09The tiny line in a table row grows into a card

What moves

Hover a row or reach it with the keyboard and it lifts into a card where the stroke thickens and the axis and peak marker appear.

The tiny line in a table row grows into a card
Open in a new tab

09 · The tiny line in a table row grows into a card — Measured area 26.139% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

vector-effectgrid-template-rows:focus-visible

What to change

A single grid-template-rows declaration owns the height budget: the open row takes 68px while the other two shrink to 20px, so the list stays 108px tall and the card never outgrows its stage.

.sp__rows {
  display: grid;
  grid-template-rows: $fold $fold $fold;
  transition-property: grid-template-rows;
}
.sp__rows:has(.sp__row[data-i="0"]:focus-visible) { grid-template-rows: $open $squash $squash; }
.sp__rows:has(.sp__row[data-i="1"]:focus-visible) { grid-template-rows: $squash $open $squash; }
.sp__rows:has(.sp__row[data-i="2"]:focus-visible) { grid-template-rows: $squash $squash $open; }
.sp__line { vector-effect: non-scaling-stroke; }

Where it breaks: a value that lands early becomes the cover image

What took the longest here was timing, not code. The preview's cover image is whichever of the 24 frames differs most from the one before it, so whenever a number lands before the picture does, some frame in between becomes the cover. Item 03 proved it. The bright band stretches smoothly with scaleX, but the total and the brightening of the outside bars were cut at 15%, the instant the band departs. The frame that got picked showed a band five cells wide under a header reading 1.65 million won across 14 days. Moving all three to 40%, the moment the band finishes arriving, and finishing the return by 88% removed every mismatched frame. Item 06 failed in the opposite direction. While the two series traded places, interpolating the horizontal and vertical moves together sent the black bar diagonally through the blue one, and that overlap was the largest change in the loop, so it became the cover. Splitting the move into two stages, sideways first and downward second, deleted the overlap outright. Item 02 was wrong about the value rather than the timing. The preview kept showing a total of 3,290 while the search series was folded away, yet pressing the chip for real makes JavaScript recount to 1,590, so the preview and the interaction were saying different things. Replacing the header with the axis maximum and registering that number with @property so a counter draws it made 460 and 260 split correctly in the preview too. None of the three is visible by reading the code; each one takes opening the frames one at a time, so all nine cover images were checked by eye before moving on. The same drill produced a still check: opening the page with .is-demo stripped and confirming that every number, label, and selection mark still shows. Values that live only in the loop leave the screen blank the moment a real hand touches the control, and neither the measurement nor the gates look at anything but the loop. Two more surfaced outside the frames. The first was the summary table in item 01. Hiding it visually with width: 1px and clip-path on the table itself let the table grow to its content, stacking twelve rows into a 411px-tall document inside a 480×300 stage. Nothing was visible on screen and only the scrollbar gave it away, and the answer was to move those rules onto a wrapping div. The second was the icon and the snapping in item 07. The arc coordinates in the Phosphor icon on the goal handle should read A104.1 104.1 0 1 1, but a 1 had crept into the rotation slot, making it A104.1 104.1 1 1 1. Two overlapping circles in a target shape look fine to the eye, and it only surfaced when the file was compared character by character against the original SVG. The same demo dropped its rounding step, which snapped the target to multiples of ten. Autoplay rolls the value continuously and shows scenes like a target of 337, so leaving the snap in means no amount of dragging ever reaches that number and the preview becomes a lie. The rule for this edition collapses into one line: every scene the preview shows has to be a scene a person can actually produce by hand. Showing code fragments beside values is a window Code Block UI Patterns opens instead. The archive password for the nine is 3b3n74ca and the vanilla and React ports sit side by side under matching folder names.

Accessibility: what survives reduced-motion

All nine stop their autoplay loops under prefers-reduced-motion: reduce while keeping values and states. Those declarations carry !important, because a selector shaped like .x.is-demo .y wins on specificity otherwise. Whatever speaks the state aloud stays put when the animation is gone. Items 01 and 08 read the pinned point and the gap as one sentence through a single aria-live="polite" region, the handle in 07 announces its target through role="slider" and aria-valuetext, and the chip in 02, the bar in 04, and the segment in 05 hold their state in aria-pressed, aria-expanded, and aria-selected. Every chart carries role="img" with an aria-label containing real figures, plus a summary table holding the same numbers, hidden visually inside the card. Hiding it by putting width: 1px on the table stretches the document to 411px, so the rule belongs on the wrapping div. The keyboard is not decorative either: 01 and 08 step between points with the arrow keys, 03 moves the end with the arrows and the start with Shift, 05 walks the segments, 07 raises the goal line, and 09 changes rows.

Text and bar contrast was computed directly from the WCAG relative luminance formula. Translucent text was measured against the color it composites onto.

Where Front Back Ratio
01, 03, 04 body ink on white card #17141a #ffffff 18.24:1
01, 02, 03 figures in blue on white card #2f6df6 #ffffff 4.53:1
08 prompt in yellow on ink card #ffd23f #17141a 12.63:1
Secondary text, ink at 62% on white card #17141a 62% #ffffff 5.12:1
02, 07 series bars in darkened mint on white card #3d9e7f #ffffff 3.28:1

Two kinds of failure got fixed. The first was deliberately faint text such as axis labels, units, and switched-off chips, scattered between 3.44:1 and 4.45:1 at ink opacities of 50% to 58%; all fifty-five went to 62% for 5.12:1. The second was bar color. Pastel mint #4cd4a6 was designed for dark stages and manages only 1.86:1 on a white card, which dissolves the bar into its background. Mixing that mint with ink at 72:28 brings it to 3.28:1 and clears the 3:1 minimum for non-text contrast. In 07 the color is not the only signal anyway, since sitting above the goal line says the same thing, so nothing rests on color alone. The neighboring 9 dashboard components covers parts from the same pillar, and the dashboard category collects the rest. The registration syntax follows MDN @property.

Frequently asked questions

What does a graph ui design give up by skipping a charting library

Automatic tick calculation, log scales, and rendering thousands of points are all things a library would have handled for you. These nine take the other side of that trade. For a dashboard card with a dozen points and one or two interactions, keeping the bar height in a single custom property and letting CSS interpolate it means less code and a clearer place to edit than carrying a bundle and the DOM structure that comes with it. When the picked values must flow into a cart, Checkout Page Design readies the next door.

Can I animate bar height without @property

An unregistered custom property is treated as a plain string, so there is no midpoint between two values. The bar jumps instead of growing, and in the autoplay preview the moving-frame count drops to two or three. Declaring syntax as "<percentage>" or "<integer>" along with an initial-value is what fills the gap.

Why snap the crosshair to the nearest point

Without snapping, the value a reader sees and the spot the cursor marks drift apart. A rule standing between two days forces a second look to work out which day it belongs to, and assistive technology has nothing specific to point at. One line rounding the cell ratio keeps the cursor position and the announced value on the same day every time.

Enter the archive password

The password is inside this article. You will find it as you read.