HTML 10 lines
<div class="chart">
<div class="scene">
<div class="candles3d"></div>
</div>
<output></output>
<div class="seg">
<button type="button" data-days="7">7D</button>
<button type="button" data-days="12">12D</button>
</div>
</div>
CSS 295 lines
.chart {
display: grid;
justify-items: center;
gap: 10px;
font-family: system-ui, sans-serif;
}
.chart * {
box-sizing: border-box;
}
.scene {
perspective: 800px;
padding: 44px 50px 36px;
pointer-events: none; /* the chart is turned: only the candles' columns take the pointer */
}
.candles3d {
--n: 12; /* days shown; JS sets it */
--slot: calc(180px / var(--n)); /* each day's column */
position: relative;
width: 180px;
height: 100px; /* the price scale */
transform-style: preserve-3d;
transform: rotateX(-18deg) rotateY(-26deg);
}
/* the floor: a neon grid laid flat under the candles */
.floor {
position: absolute;
left: -10px;
top: 85px;
width: 200px;
height: 30px;
border: 1px solid rgb(139 108 255 / 0.45);
border-radius: 6px;
background:
repeating-linear-gradient(90deg, rgb(139 108 255 / 0.22) 0 1px, transparent 1px 15px),
repeating-linear-gradient(rgb(139 108 255 / 0.22) 0 1px, transparent 1px 15px),
rgb(139 108 255 / 0.09);
transform: rotateX(90deg);
}
/* the price scale: lines at the bottom, middle and top, along the back of the floor; the numbers
at the right end, which reaches out past the last candle. The wall reaches 8px past the scale
at both ends (a label sticking out of a 3D-placed box gets its top half cut), so its lines
are 1px gradients at 8px, 58px and 107px */
.wall {
--line: rgb(236 238 251 / 0.24);
position: absolute;
top: -8px;
left: -10px;
width: 200px;
height: 116px;
background:
linear-gradient(var(--line), var(--line)) 0 8px / 100% 1px no-repeat,
linear-gradient(var(--line), var(--line)) 0 58px / 100% 1px no-repeat,
linear-gradient(var(--line), var(--line)) 0 107px / 100% 1px no-repeat;
transform: translateZ(-15px);
}
.wall span {
position: absolute;
bottom: calc(8px + var(--t) * 100px);
left: 100%;
padding-left: 6px;
color: #949bc0;
font: 700 9px/12px system-ui, sans-serif;
white-space: nowrap;
transform: translateY(50%);
}
/* the first and last day shown, at the front edge of the floor */
.date {
position: absolute;
top: calc(100% + 6px);
left: 0;
color: #949bc0;
font: 700 9px/12px system-ui, sans-serif;
transform: translateZ(15px);
}
.date:last-of-type {
right: 0;
left: auto;
}
/* One day: a column the full height of the chart and a slot wide. It is the hover target and
never moves on hover. Centred on its x, so a new slot width changes only the transform. */
.candle {
--tone: #2ee6d6; /* closed up */
--b: min(var(--o), var(--c)); /* the body's bottom… */
--bh: max(var(--o) - var(--c), var(--c) - var(--o), 0.012); /* …and its height, never 0 */
position: absolute;
top: 0;
left: 0;
width: var(--slot);
height: 100%;
margin-left: calc(var(--slot) * -0.5);
outline: none;
cursor: pointer;
pointer-events: auto;
transform-origin: bottom center;
transform-style: preserve-3d;
/* --in: 1 in the range, 0 outside it (sunk into the floor) */
transform: translateX(calc((var(--x) + 0.5) * var(--slot))) scaleY(var(--in));
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1) calc(var(--i) * 30ms);
}
.candle.is-down {
--tone: #ff4d9d;
}
.candle.is-out {
pointer-events: none;
}
.candle i {
position: absolute;
top: 0;
left: 50%;
height: 100%;
pointer-events: none;
transform-origin: bottom center; /* scaleY grows up from the floor */
transform-style: preserve-3d;
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1) calc(var(--i) * 30ms);
}
.candle i::before,
.candle i::after {
content: '';
position: absolute;
}
/* the wick: low → high, and a second line crossed at 90° so it has depth */
.wick {
width: 2px;
margin-left: -1px;
background: var(--tone);
box-shadow: 0 0 6px color-mix(in srgb, var(--tone) 70%, transparent);
transform: translateY(calc(var(--lo) * -100px)) scaleY(calc(var(--hi) - var(--lo)));
}
.wick::before {
inset: 0;
background: inherit;
transform: rotateY(90deg);
}
/* the body: open ↔ close, coloured glass (the wick shows through). The element is the front,
pushed half its depth towards you */
.body {
/* a fine edge: under 1px shows as a hairline on sharp screens */
--edge: 0.6px solid color-mix(in srgb, color-mix(in srgb, var(--tone) 80%, #fff) 75%, transparent);
width: 9px;
margin-left: -4.5px;
border: var(--edge);
background: color-mix(in srgb, var(--tone) 58%, transparent);
box-shadow:
inset 0 0 8px color-mix(in srgb, var(--tone) 30%, transparent),
0 0 10px color-mix(in srgb, var(--tone) 22%, transparent);
transform: translateY(calc(var(--b) * -100px)) scaleY(var(--bh)) translateZ(4.5px);
}
/* right side, darker: hinged on the front's right edge, turned back */
.body::before {
top: -0.6px;
left: 100%;
width: 9px;
height: calc(100% + 1.2px);
border: var(--edge);
background: color-mix(in srgb, color-mix(in srgb, var(--tone) 55%, #05060c) 64%, transparent);
transform-origin: left center;
transform: rotateY(90deg);
}
/* the lid: hinged on the front's top edge, laid flat; scaleY only carries it up */
.body::after {
top: -0.6px;
left: -0.6px;
width: 9px;
height: 9px;
border: var(--edge);
background: color-mix(in srgb, var(--tone) 80%, transparent);
transform-origin: center top;
transform: rotateX(-90deg);
}
/* the pointed-at candle (.is-active) fills in and glows: an overlay on the body's front, the
same transform a hair nearer, faded with opacity */
.candle::after {
content: '';
position: absolute;
top: 0;
left: 50%;
width: 9px;
height: 100%;
margin-left: -4.5px;
background: color-mix(in srgb, var(--tone) 50%, transparent);
box-shadow: 0 0 20px color-mix(in srgb, var(--tone) 60%, transparent);
opacity: 0;
pointer-events: none;
transform-origin: bottom center;
transform: translateY(calc(var(--b) * -100px)) scaleY(var(--bh)) translateZ(4.7px);
transition:
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1) calc(var(--i) * 30ms),
opacity 0.25s;
}
.candle.is-active::after {
opacity: 1;
}
/* ONE tooltip for the chart: JS sets --tx / --ty (the candle's middle and its high, in px) and
it glides there. 40px towards you; --f (0 first day … 1 last) makes the ends hang inwards.
Flat on purpose: its thickness is a hard edge up and to the right, where the depth runs. */
.tip {
--tone: #2ee6d6;
position: absolute;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--tone);
border-radius: 6px;
background: #141830;
box-shadow:
3px -3px 0 color-mix(in srgb, var(--tone) 55%, #05060c),
0 0 14px color-mix(in srgb, var(--tone) 40%, transparent);
color: #eceefb;
font: 700 9px/12px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(var(--tx, 0px), calc(var(--ty, 0px) - 12px)) translate(calc(var(--f, 0) * -100%), -100%) translateZ(40px);
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.2s;
}
.tip.is-down {
--tone: #ff4d9d;
}
.tip.is-on {
opacity: 1;
}
output {
color: #949bc0;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* two buttons and one pill behind them that slides to the pressed one */
.seg {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 3px;
border: 1px solid rgb(140 150 220 / 0.34);
border-radius: 999px;
}
.seg::before {
content: '';
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
width: calc(50% - 4px);
border-radius: 999px;
background: linear-gradient(135deg, #8b6cff, #ff4d9d);
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.seg:has(button:last-child[aria-pressed='true'])::before {
transform: translateX(calc(100% + 2px));
}
.seg button {
position: relative; /* above the pill */
padding: 4px 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: #949bc0;
font: 700 12px system-ui, sans-serif;
cursor: pointer;
}
.seg button[aria-pressed='true'] {
color: #fff;
}
JS 151 lines
// The data, as a market API would send it back: one object per day
const CANDLES = [
{ "date": "Sep 08", "open": 57120, "high": 58450, "low": 56800, "close": 58100 },
{ "date": "Sep 09", "open": 58100, "high": 58900, "low": 57350, "close": 57540 },
{ "date": "Sep 10", "open": 57540, "high": 57980, "low": 56120, "close": 56480 },
{ "date": "Sep 11", "open": 56480, "high": 57700, "low": 55900, "close": 57390 },
{ "date": "Sep 12", "open": 57390, "high": 59100, "low": 57210, "close": 58860 },
{ "date": "Sep 13", "open": 58860, "high": 60250, "low": 58400, "close": 59980 },
{ "date": "Sep 14", "open": 59980, "high": 60400, "low": 58950, "close": 59240 },
{ "date": "Sep 15", "open": 59240, "high": 61200, "low": 59020, "close": 60870 },
{ "date": "Sep 16", "open": 60870, "high": 61050, "low": 59600, "close": 59820 },
{ "date": "Sep 17", "open": 59820, "high": 60340, "low": 58710, "close": 58990 },
{ "date": "Sep 18", "open": 58990, "high": 60120, "low": 58600, "close": 59770 },
{ "date": "Sep 19", "open": 59770, "high": 60480, "low": 59310, "close": 59860 }
];
const chart = document.querySelector('.candles3d');
const out = document.querySelector('.chart output');
const buttons = document.querySelectorAll('.seg button');
const STEP = 1000; // the scale is rounded out to whole thousands
const W = 180, H = 100; // the chart's size in px, as in the CSS
// Prices the way trading apps write them: $61,200, $58.5k, +4.8%, −1.2% (a real minus)
const usd = (v) => '$' + v.toLocaleString('en-US');
const usdK = (v) => '$' + (v / 1000).toLocaleString('en-US', { maximumFractionDigits: 1 }) + 'k';
const pct = (v) => (v < 0 ? '−' : '+') + Math.abs(v).toFixed(1) + '%';
const tipText = (d) => `${d.date} · O ${usd(d.open)} H ${usd(d.high)} L ${usd(d.low)} C ${usd(d.close)}`;
// Build the chart once: the floor, the scale, two date labels, a column per day holding a wick
// and a body, and ONE tooltip. Only empty markup goes in as HTML.
chart.innerHTML =
'<div class="floor"></div>' +
'<div class="wall">' + [0, 0.5, 1].map((t) => `<span style="--t:${t}"></span>`).join('') + '</div>' +
'<span class="date"></span><span class="date"></span>' +
CANDLES.map((_, i) => `<div class="candle" style="--i:${i}"><i class="wick"></i><i class="body"></i></div>`).join('') +
'<b class="tip" aria-hidden="true"></b>';
const candles = [...chart.querySelectorAll('.candle')];
const ticks = chart.querySelectorAll('.wall span');
const dates = chart.querySelectorAll('.date');
const tip = chart.querySelector('.tip');
// The words come from the data, so they go in with textContent (or an attribute), never as
// HTML: an API's response is not trusted markup
CANDLES.forEach((d, i) => {
candles[i].setAttribute('aria-label', tipText(d));
candles[i].classList.toggle('is-down', d.close < d.open);
});
let view; // what is shown now: n, offset and the price → fraction function
let active = -1;
let hideTimer = 0;
// Show the last n days: every price becomes a fraction of the visible range. CSS does the rest.
function show(n) {
const offset = CANDLES.length - n;
const days = CANDLES.slice(offset);
const low = Math.min(...days.map((d) => d.low));
const high = Math.max(...days.map((d) => d.high));
const min = Math.floor(low / STEP) * STEP;
const max = Math.ceil(high / STEP) * STEP;
const f = (p) => (p - min) / (max - min);
view = { n, offset, f };
chart.style.setProperty('--n', n);
candles.forEach((el, i) => {
const x = i - offset; // its slot; below 0: outside the range
const d = CANDLES[i];
const shown = x >= 0;
el.style.setProperty('--x', shown ? x : -1);
el.style.setProperty('--in', shown ? 1 : 0); // 0: sinks into the floor
if (shown) {
el.style.setProperty('--lo', f(d.low).toFixed(3));
el.style.setProperty('--hi', f(d.high).toFixed(3));
el.style.setProperty('--o', f(d.open).toFixed(3));
el.style.setProperty('--c', f(d.close).toFixed(3));
}
el.classList.toggle('is-out', !shown);
el.tabIndex = shown ? 0 : -1;
el.toggleAttribute('aria-hidden', !shown);
});
[min, (min + max) / 2, max].forEach((p, k) => (ticks[k].textContent = usdK(p)));
dates[0].textContent = days[0].date;
dates[1].textContent = days[n - 1].date;
const change = ((days[n - 1].close - days[0].open) / days[0].open) * 100;
out.textContent = `${n} days · ${pct(change)} · high ${usd(high)}`;
buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.days === String(n)));
// the tooltip follows its candle to the new place, or goes if the candle left the range
if (active >= offset) place(active);
else if (active >= 0) hideNow();
}
// One tooltip: it glides from candle to candle (a transform transition) and its text changes on
// the way. JS only gives it the candle's place.
function place(i) {
clearTimeout(hideTimer);
const d = CANDLES[i];
const x = i - view.offset;
const wasOn = tip.classList.contains('is-on');
if (!wasOn) tip.style.transition = 'none'; // from hidden: appear in place, don't fly in
tip.textContent = tipText(d);
tip.style.setProperty('--tx', ((x + 0.5) * W) / view.n + 'px'); // the candle's middle
tip.style.setProperty('--ty', (1 - view.f(d.high)) * H + 'px'); // its high
tip.style.setProperty('--f', x / (view.n - 1)); // 0 first … 1 last: how far it hangs left
tip.classList.toggle('is-down', d.close < d.open);
if (!wasOn) {
void tip.offsetWidth; // apply the new place before the transition comes back
tip.style.transition = '';
}
tip.classList.add('is-on');
candles.forEach((c, k) => c.classList.toggle('is-active', k === i));
active = i;
}
function hideNow() {
clearTimeout(hideTimer);
tip.classList.remove('is-on');
candles.forEach((c) => c.classList.remove('is-active'));
active = -1;
}
// a short grace period, so crossing from one candle to the next does not flicker it
function hide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(hideNow, 150);
}
const candleOf = (e) => e.target.closest('.candle:not(.is-out)');
function over(e) {
const c = candleOf(e);
if (c) place(candles.indexOf(c));
}
function leave(e) {
if (!e.relatedTarget?.closest?.('.candle')) hide();
}
chart.addEventListener('pointerover', over);
chart.addEventListener('focusin', over);
chart.addEventListener('pointerout', leave);
chart.addEventListener('focusout', leave);
// a finger has no hover: a tap on a candle shows its tooltip, a tap elsewhere hides it
document.addEventListener('pointerup', (e) => {
if (e.pointerType === 'mouse') return;
const c = candleOf(e);
if (c) place(candles.indexOf(c));
else hide();
});
buttons.forEach((b) => b.addEventListener('click', () => show(Number(b.dataset.days))));
show(12);
Sass source 326 lines
// A candlestick chart drawn from JSON. JS writes four numbers per candle, each a price as a
// fraction of the visible range (0 = the bottom of the scale, 1 = the top): --lo, --hi, --o, --c.
// Every part is a full-height box squashed with scaleY and lifted with translateY, so a new range
// is only a transition on transform: the wick spans low → high, the body open ↔ close.
// Each candle's column is placed with translateX(--x × slot), and the slot is the chart's width
// divided by the number of days shown (--n), so switching 12D → 7D spreads the last seven out while
// the others sink into the floor.
$w: 180px; // the chart's width
$h: 100px; // the price scale's height
$cw: 9px; // a candle body's width, and depth
$floor: 30px; // the floor's depth, front to back
$ease: cubic-bezier(0.22, 1, 0.36, 1); // no overshoot: a price should not bounce past itself
.d-candles {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
width: 100%;
height: 100%;
padding: 10px 14px 14px; // the control dock: the same place in every model with controls
// The chart is turned, so its left candles sit behind the plane of the flat boxes around it,
// which would catch the pointer first: only the candles' columns and the dock take it.
pointer-events: none;
&__chart {
--slot: calc(#{$w} / var(--n, 12));
position: relative;
place-self: center;
width: $w;
height: $h;
margin-top: 16px; // room for the tooltips above the highest candle
pointer-events: none;
transform-style: preserve-3d;
transform: rotateX(-18deg) rotateY(-26deg);
}
// the floor: a neon grid laid flat under the candles
&__floor {
position: absolute;
left: -10px;
top: $h - $floor * 0.5;
width: $w + 20px;
height: $floor;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
border-radius: 6px;
background:
repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent) 0 1px, transparent 1px 15px),
repeating-linear-gradient(color-mix(in srgb, var(--accent) 22%, transparent) 0 1px, transparent 1px 15px),
color-mix(in srgb, var(--accent) 9%, transparent);
transform: rotateX(90deg);
}
// the price scale: lines at the bottom, middle and top, on a wall along the back edge of the
// floor. The numbers sit at its right end, which reaches out past the last candle (the left
// end is hidden behind the first ones). The wall reaches 8px past the scale at both ends and
// draws its lines as 1px gradients: a label sticking out of a 3D-placed box got its top half cut.
&__wall {
--line: color-mix(in srgb, var(--text) 24%, transparent);
position: absolute;
top: -8px;
left: -10px;
width: $w + 20px;
height: $h + 16px;
background:
linear-gradient(var(--line), var(--line)) 0 8px / 100% 1px no-repeat,
linear-gradient(var(--line), var(--line)) 0 #{8px + $h * 0.5} / 100% 1px no-repeat,
linear-gradient(var(--line), var(--line)) 0 #{8px + $h - 1px} / 100% 1px no-repeat;
transform: translateZ($floor * -0.5);
span {
position: absolute;
bottom: calc(8px + var(--t) * #{$h});
left: 100%;
padding-left: 6px;
color: var(--muted);
font: 700 9px/12px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transform: translateY(50%);
}
}
// the first and last day shown, standing at the front edge of the floor
&__date {
position: absolute;
top: calc(100% + 6px);
color: var(--muted);
font: 700 9px/12px system-ui, sans-serif;
white-space: nowrap;
transform: translateZ($floor * 0.5);
&:first-of-type {
left: 0;
}
&:last-of-type {
right: 0;
}
}
// One candle's column: the whole height of the chart and a slot wide, so it is the hover target
// (the empty space above and below a candle counts too). It never moves on hover; it only
// slides when the range changes. Centred on its x (margin −½ slot + translateX to the middle
// of the slot), so a new slot width changes nothing but the transform.
&__candle {
--tone: var(--accent-2);
--b: min(var(--o), var(--c)); // the body's bottom…
--bh: max(var(--o) - var(--c), var(--c) - var(--o), 0.012); // …and its height (never 0)
position: absolute;
top: 0;
left: 0;
width: var(--slot);
height: 100%;
margin-left: calc(var(--slot) * -0.5);
outline: none;
cursor: pointer;
pointer-events: auto;
transform-origin: bottom center;
transform-style: preserve-3d;
// --in is 1 for a day in the range, 0 for one outside it: it sinks into the floor
transform: translateX(calc((var(--x) + 0.5) * var(--slot))) scaleY(var(--in));
transition: transform 0.7s $ease calc(var(--i) * 30ms);
&.is-down {
--tone: var(--hot);
}
&.is-out {
pointer-events: none;
}
// the pointed-at candle (.is-active, set by JS with the tooltip) fills in and glows: an
// overlay on the body's front, the same transform, a hair nearer; faded with opacity
&::after {
content: '';
position: absolute;
top: 0;
left: 50%;
width: $cw;
height: 100%;
margin-left: $cw * -0.5;
background: color-mix(in srgb, var(--tone) 50%, transparent);
box-shadow: 0 0 20px color-mix(in srgb, var(--tone) 60%, transparent);
opacity: 0;
pointer-events: none;
transform-origin: bottom center;
transform: translateY(calc(var(--b) * -#{$h})) scaleY(var(--bh)) translateZ($cw * 0.5 + 0.2px);
transition:
transform 0.7s $ease calc(var(--i) * 30ms),
opacity 0.25s;
}
&.is-active::after {
opacity: 1;
}
i {
position: absolute;
top: 0;
left: 50%;
height: 100%;
pointer-events: none;
transform-origin: bottom center;
transform-style: preserve-3d;
transition: transform 0.7s $ease calc(var(--i) * 30ms);
&::before,
&::after {
content: '';
position: absolute;
}
}
}
// the wick: a 2px line from low to high, crossed by a second one turned 90° so it has depth
&__wick {
width: 2px;
margin-left: -1px;
background: var(--tone);
box-shadow: 0 0 6px color-mix(in srgb, var(--tone) 70%, transparent);
transform: translateY(calc(var(--lo) * -#{$h})) scaleY(calc(var(--hi) - var(--lo)));
&::before {
inset: 0;
background: inherit;
transform: rotateY(90deg);
}
}
// the body: a cuboid of coloured glass from open to close (the wick shows through it). The
// element is its front face, ::before its right side and ::after its lid; the lid is flat, so
// the parent's scaleY only moves it to the top.
&__body {
// a fine edge: under 1px shows as a hairline on sharp screens (a standard one draws 1px)
--edge: 0.6px solid color-mix(in srgb, color-mix(in srgb, var(--tone) 80%, #fff) 75%, transparent);
width: $cw;
margin-left: $cw * -0.5;
border: var(--edge);
background: color-mix(in srgb, var(--tone) 58%, transparent);
box-shadow:
inset 0 0 8px color-mix(in srgb, var(--tone) 30%, transparent),
0 0 10px color-mix(in srgb, var(--tone) 22%, transparent);
transform: translateY(calc(var(--b) * -#{$h})) scaleY(var(--bh)) translateZ($cw * 0.5);
// right side, darker: hinged on the front's right edge and turned back
&::before {
top: -0.6px;
left: 100%;
width: $cw;
height: calc(100% + 1.2px);
border: var(--edge);
background: color-mix(in srgb, color-mix(in srgb, var(--tone) 55%, #05060c) 64%, transparent);
transform-origin: left center;
transform: rotateY(90deg);
}
// the lid, brighter: hinged on the front's top edge and laid flat, backwards
&::after {
top: -0.6px;
left: -0.6px;
width: $cw;
height: $cw;
border: var(--edge);
background: color-mix(in srgb, var(--tone) 80%, transparent);
transform-origin: center top;
transform: rotateX(-90deg);
}
}
// One tooltip for the chart: JS moves it to the pointed-at candle (--tx, --ty: the candle's
// middle and its high, in px) and CSS glides it there, so it slides from candle to candle with
// its text changing instead of blinking. It lives in the chart's turned plane, 40px towards
// you, or the nearer candles on the right would cover it. --f (0 for the first day shown, 1 for
// the last) slides it from hanging right of its candle to hanging left, so the ends stay over
// the chart. It is flat: its thickness is a hard edge offset up and to the right, the way the
// candles' depth runs (a 3D plate flattens and flickers while the tooltip fades).
&__tip {
--tone: var(--accent-2);
position: absolute;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--tone);
border-radius: 6px;
background: var(--surface-solid);
box-shadow:
3px -3px 0 color-mix(in srgb, var(--tone) 55%, #05060c),
0 0 14px color-mix(in srgb, var(--tone) 40%, transparent);
color: var(--text);
font: 700 9px/12px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(var(--tx, 0px), calc(var(--ty, 0px) - 12px)) translate(calc(var(--f, 0) * -100%), -100%) translateZ(40px);
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.2s;
&.is-down {
--tone: var(--hot);
}
&.is-on {
opacity: 1;
}
}
&__dock {
display: grid;
justify-items: center;
gap: 6px;
font-size: 12px;
pointer-events: auto;
output {
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
// two buttons and one pill behind them, which slides to the pressed one
&__seg {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 3px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: color-mix(in srgb, var(--bg) 70%, transparent);
&::before {
content: '';
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
width: calc(50% - 4px);
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--hot));
transition: transform 0.35s $ease;
}
&:has(button:last-child[aria-pressed='true'])::before {
transform: translateX(calc(100% + 2px));
}
button {
position: relative; // above the pill
padding: 4px 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
&[aria-pressed='true'] {
color: #fff;
}
}
}
}