HTML 9 lines
<div class="panel3d" tabindex="0" aria-label="Monthly revenue">
<div class="panel">
<div class="glass"></div>
<svg class="area" viewBox="0 0 200 110" aria-hidden="true"><path /></svg>
<svg class="line" viewBox="0 0 200 110" aria-hidden="true"><path class="glow" /><path /></svg>
<div class="dots"></div>
<b class="title">Revenue <small>$k</small></b>
</div>
</div>
CSS 134 lines
.panel3d {
/* one base unit: every length of the panel is a multiple of it, so it is the same share of a
card, the editor, a full screen and a recording canvas */
--u: 0.4vmin;
position: relative;
width: calc(200 * var(--u));
height: calc(110 * var(--u));
/* more room below than above: the panel's shadow falls well below it, so this is what
centres what is drawn rather than the panel's box. None at the sides: with them the box was
112vmin wide, wider than a square canvas, and a box wider than the canvas does not centre, it
hangs off the right-hand side (6vmin off the middle on a 1:1 or 9:16 canvas) */
margin: calc(32 * var(--u)) 0 calc(48 * var(--u));
outline: none;
transform-style: preserve-3d;
perspective: calc(800 * var(--u));
font-family: Inter, system-ui, sans-serif;
}
/* the tilt lives here; the wrapper above stays still, so hovering it never flickers */
.panel {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: rotateX(40deg) rotateY(-16deg) rotateZ(5deg);
transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.panel3d:hover .panel,
.panel3d:focus-visible .panel {
transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg);
}
/* the glass, with faint value lines */
.glass {
position: absolute;
inset: 0;
border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.5);
border-radius: calc(12 * var(--u));
background:
repeating-linear-gradient(transparent 0 calc(21 * var(--u)), rgb(236 238 251 / 0.1) calc(21 * var(--u)) calc(22 * var(--u))) 0 calc(12 * var(--u)) / 100% calc(100% - calc(24 * var(--u))) no-repeat,
linear-gradient(150deg, #2a2560, #141830);
box-shadow: 0 calc(18 * var(--u)) calc(36 * var(--u)) calc(-14 * var(--u)) rgb(0 0 0 / 0.55);
}
.panel svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.area {
transform: translateZ(calc(5 * var(--u)));
}
.area path {
fill: rgb(46 230 214 / 0.16);
}
.line {
transform: translateZ(calc(10 * var(--u)));
}
.line path {
fill: none;
stroke: #2ee6d6;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the glow: the same path, wide and faint, under the line */
.line .glow {
stroke-width: 9;
stroke-opacity: 0.22;
}
.dots {
position: absolute;
inset: 0;
transform: translateZ(calc(14 * var(--u)));
}
.dots i {
position: absolute;
/* --x, --y: plain numbers in the panel's own units, from JS */
top: calc(var(--y) * var(--u));
left: calc(var(--x) * var(--u));
width: calc(7 * var(--u));
height: calc(7 * var(--u));
margin: calc(-3.5 * var(--u)) 0 0 calc(-3.5 * var(--u));
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 calc(2 * var(--u)) #2ee6d6, 0 0 calc(10 * var(--u)) #2ee6d6;
}
/* values: hidden while tilted (except the latest), shown when the panel lies flat */
.dots span {
position: absolute;
bottom: calc(9 * var(--u));
left: 50%;
padding: calc(1 * var(--u)) calc(3 * var(--u));
border-radius: calc(4 * var(--u));
background: rgb(20 24 48 / 0.85);
color: #eceefb;
font: 700 calc(10 * var(--u))/calc(12 * var(--u)) Inter, system-ui, sans-serif;
white-space: nowrap;
opacity: 0;
translate: -50% 0;
transition: opacity 0.3s;
}
.dots i:last-child span,
.panel3d:hover .dots span,
.panel3d:focus-visible .dots span {
opacity: 1;
}
.title {
position: absolute;
top: calc(9 * var(--u));
left: calc(12 * var(--u));
color: #eceefb;
font: 800 calc(10 * var(--u))/calc(12 * var(--u)) Inter, system-ui, sans-serif;
letter-spacing: 0.04em;
transform: translateZ(calc(6 * var(--u)));
}
.title small {
color: #949bc0;
font-weight: 700;
}
JS 37 lines
// The data, as an API would send it back
const TREND = {
"unit": "$k",
"labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"values": [18, 24, 21, 30, 27, 36, 33, 41, 37, 47, 44, 55]
};
// The panel is 200 × 110 units of --u and the SVG viewBox is the same, so one SVG unit is one --u
const W = 200, H = 110;
// room round the points: a dot floats 14 units over the tilted glass and is seen up to 9 units
// off the spot under it, and the highest value's label stands over its dot
const SIDE = 24, TOP = 36, BOTTOM = 16;
const lo = Math.min(...TREND.values);
const hi = Math.max(...TREND.values);
// JSON → points: x evenly along the width, y by where the value sits between lo and hi
const points = TREND.values.map((v, i) => [
SIDE + (i * (W - 2 * SIDE)) / (TREND.values.length - 1),
H - BOTTOM - ((v - lo) / (hi - lo)) * (H - BOTTOM - TOP),
]);
// points → one path: "M x y L x y …"; closed down to the bottom, it is the area
const line = 'M' + points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(' L');
const area = `${line} L${points.at(-1)[0]} ${H} L${points[0][0]} ${H} Z`;
document.querySelectorAll('.line path').forEach((p) => p.setAttribute('d', line));
document.querySelector('.area path').setAttribute('d', area);
// a dot per value, with its number (shown when the panel lies flat)
const dots = document.querySelector('.dots');
points.forEach(([x, y], i) => {
const dot = document.createElement('i');
dot.style.cssText = `--x:${x.toFixed(1)}; --y:${y.toFixed(1)}`; // plain numbers: CSS multiplies them by --u
const label = document.createElement('span');
label.textContent = TREND.values[i];
dot.append(label);
dots.append(dot);
});