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
- 01 Crosshair that snaps to the nearest point
- 02 Turning a legend off regrows the other bars
- 03 Drag a range and the summary recounts
- 04 A month bar splits into days where it stands
- 05 7, 30 or 90 days reshapes the bars
- 06 Stacked and grouped show the same numbers twice
- 07 Drag the goal line and only the bars above change color
- 08 Pin two points and the gap appears
- 09 The tiny line in a table row grows into a card
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.
01Crosshair that snaps to the nearest point
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. 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] + '명';
val.textContent = (cur + 1) + '일 · ' + nums[cur] + '명';
}
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
Press a legend chip and that series folds to the baseline, then the remaining maximum is recomputed and every remaining bar grows taller. 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 = '축 최대 ' + max + '명';
}
03Drag a range and the summary recounts
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. 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
Clicking a month bar swaps nothing out; the bar opens into five daily bars right where it stood. 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
Changing the range keeps all eight bars; only their heights flow to the new values. 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
One toggle splits the same data between stacked bars and side-by-side bars. 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
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. 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 + '명');
}
08Pin two points and the gap appears
The first click pins a baseline, the second draws a dashed span with the difference, and a third moves the baseline to that point. 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
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. 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. 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 do I 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.
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.