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 126 lines
.panel3d {
position: relative;
width: 200px;
height: 110px;
margin: 40px;
outline: none;
transform-style: preserve-3d;
perspective: 800px;
font-family: 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: 1px solid rgb(139 108 255 / 0.5);
border-radius: 12px;
background:
repeating-linear-gradient(transparent 0 21px, rgb(236 238 251 / 0.1) 21px 22px) 0 12px / 100% calc(100% - 24px) no-repeat,
linear-gradient(150deg, #2a2560, #141830);
box-shadow: 0 18px 36px -14px rgb(0 0 0 / 0.55);
}
.panel svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.area {
transform: translateZ(8px);
}
.area path {
fill: rgb(46 230 214 / 0.16);
}
.line {
transform: translateZ(16px);
}
.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(22px);
}
.dots i {
position: absolute;
top: var(--y);
left: var(--x);
width: 7px;
height: 7px;
margin: -3.5px 0 0 -3.5px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 2px #2ee6d6, 0 0 10px #2ee6d6;
}
/* values: hidden while tilted (except the latest), shown when the panel lies flat */
.dots span {
position: absolute;
bottom: 9px;
left: 50%;
padding: 1px 3px;
border-radius: 4px;
background: rgb(20 24 48 / 0.85);
color: #eceefb;
font: 700 8px/10px 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: 9px;
left: 12px;
color: #eceefb;
font: 800 10px/12px system-ui, sans-serif;
letter-spacing: 0.04em;
transform: translateZ(10px);
}
.title small {
color: #949bc0;
font-weight: 700;
}
JS 35 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 × 110px and the SVG viewBox is the same, so one SVG unit is one pixel
const W = 200, H = 110, PAD = 12;
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
// (14px of headroom at the top for the title)
const points = TREND.values.map((v, i) => [
PAD + (i * (W - 2 * PAD)) / (TREND.values.length - 1),
H - PAD - ((v - lo) / (hi - lo)) * (H - 2 * PAD - 14),
]);
// 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}px; --y:${y}px`;
const label = document.createElement('span');
label.textContent = TREND.values[i];
dot.append(label);
dots.append(dot);
});
Sass source 141 lines
// A line chart on a glass panel, tilted in 3D. The chart is plain SVG (JS turns a JSON array
// into its path); the 3D is all on the containers: the panel tilts, and the area, the line and
// the dots float at different depths over it. Hovering the (still) wrapper lays the panel flat
// so the values can be read, and shows them.
$w: 200px;
$h: 110px;
.d-chartpanel {
position: relative;
width: $w;
height: $h;
outline: none;
transform-style: preserve-3d;
&__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;
}
&:hover &__panel,
&:focus-visible &__panel {
transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg);
}
// the glass, with faint value lines
&__glass {
position: absolute;
inset: 0;
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
border-radius: 12px;
background:
repeating-linear-gradient(transparent 0 21px, color-mix(in srgb, var(--text) 10%, transparent) 21px 22px) 0 12px / 100% calc(100% - 24px) no-repeat,
linear-gradient(150deg, color-mix(in srgb, var(--accent) 24%, var(--surface-solid)), var(--surface-solid));
box-shadow: 0 18px 36px -14px rgb(0 0 0 / 0.55);
}
svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
&__area {
transform: translateZ(8px);
path {
fill: color-mix(in srgb, var(--accent-2) 16%, transparent);
}
}
&__line {
transform: translateZ(16px);
path {
fill: none;
stroke: var(--accent-2);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
}
// the glow: the same path, wide and faint, under the line (cheaper than a blur filter)
&__glow {
stroke-width: 9;
stroke-opacity: 0.22;
}
&__dots {
position: absolute;
inset: 0;
transform: translateZ(22px);
i {
position: absolute;
top: var(--y);
left: var(--x);
width: 7px;
height: 7px;
margin: -3.5px 0 0 -3.5px;
border-radius: 50%;
background: #fff;
box-shadow:
0 0 0 2px var(--accent-2),
0 0 10px var(--accent-2);
}
// values: hidden while tilted (except the latest), shown when the panel lies flat
span {
position: absolute;
bottom: 9px;
left: 50%;
padding: 1px 3px;
border-radius: 4px;
background: color-mix(in srgb, var(--surface-solid) 85%, transparent);
color: var(--text);
font: 700 8px/10px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0;
translate: -50% 0;
transition: opacity 0.3s;
}
i:last-child span {
opacity: 1;
}
}
&:hover &__dots span,
&:focus-visible &__dots span {
opacity: 1;
}
&__title {
position: absolute;
top: 9px;
left: 12px;
color: var(--text);
font: 800 10px/12px system-ui, sans-serif;
letter-spacing: 0.04em;
transform: translateZ(10px);
small {
color: var(--muted);
font-weight: 700;
}
}
}
// The vertical video is narrower, for its zoom, than a card, and the flat panel is this model's
// widest state: a touch smaller there so it stays in frame (cards and pages are unaffected).
[data-reel] .d-chartpanel {
scale: 0.88;
}