HTML 12 lines
<div class="play">
<div class="view"><div class="box">3D</div></div>
<div class="controls">
<output class="caption"></output>
<div class="row">
<label>persp <input type="range" data-var="p" min="150" max="1200" value="400" aria-label="perspective"></label>
<label>rX <input type="range" data-var="rx" data-unit="deg" min="-70" max="70" value="20" aria-label="rotateX"></label>
<label>rY <input type="range" data-var="ry" data-unit="deg" min="-70" max="70" value="-35" aria-label="rotateY"></label>
</div>
</div>
</div>
CSS 163 lines
.play {
/* one base unit: every length in the box below is a multiple of it, so the playground is the
same share of a gallery card, the editor, a full screen and a recording canvas. The control
zone under it is in plain vmin, because it is the same object in every model. */
--u: 0.35vmin;
display: grid;
justify-items: center;
gap: 4vmin;
}
/* room for the box at its biggest — perspective down at 150, turned as far as the sliders go —
so nothing it draws ever reaches the controls */
.view {
display: grid;
place-items: center;
width: calc(132 * var(--u));
height: calc(132 * var(--u));
/* the slider writes a plain number; the unit is the model's, like every other length here */
perspective: calc(var(--p, 400) * var(--u));
}
.box {
display: grid;
place-items: center;
width: calc(120 * var(--u));
height: calc(120 * var(--u));
border-radius: calc(16 * var(--u));
font: 900 calc(38 * var(--u)) Inter, system-ui, sans-serif;
color: #fff;
/* deep enough for its white 3D, 3:1 or more (large text): the bright #ffb547 was 2.3:1 */
background: linear-gradient(135deg, #c96f00, #d1206f);
transform: rotateX(var(--rx, 20deg)) rotateY(var(--ry, -35deg));
}
/* the control zone: the same object, at the same size, in every model that has one — so it is
written in plain vmin and not in the box's own unit */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 'JetBrains Mono', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.controls .row {
display: flex;
gap: 2vmin;
}
.controls button,
.controls label {
height: 8vmin;
min-width: 8vmin;
padding: 0 3vmin;
box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
border: 0;
border-radius: 999px;
/* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
strength: no colour of the model's, which would vanish on one of the two stages */
background: rgb(140 150 220 / 0.2);
color: inherit;
font: 600 4vmin Inter, system-ui, sans-serif;
cursor: pointer;
transition: background-color 0.35s;
}
.controls button:hover,
.controls label:hover {
background-color: rgb(140 150 220 / 0.34);
}
/* a pill that holds a word and a slider: both on the pill's middle line, not on the text baseline,
where the slider sat a little high */
.controls label {
display: inline-flex;
align-items: center;
gap: 2vmin;
}
.controls :focus-visible {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
background: linear-gradient(135deg, #6a45f5, #d1206f);
color: #fff;
}
/* a slider, inside its pill: track and thumb in vmin, never the browser's own px, so it is the
same share of every canvas. The empty track is the unselected tint and the filled part the
selected gradient; the thumb is the stage's ink in a gradient ring, because the gradient alone
is under 3:1 on the pill's tint on the dark stage. Chrome has no filled part of its own: the
model's JS writes --pos (0 to 1) on the input as it moves, and the fill ends under the thumb */
.controls input[type='range'] {
--thumb: 4.5vmin;
appearance: none;
width: 28vmin;
height: var(--thumb);
margin: 0; /* the browser's is 2px */
background: none;
color: inherit; /* the thumb is the stage's ink: a form control does not inherit it by itself */
cursor: pointer;
}
.controls input[type='range']::-webkit-slider-runnable-track {
height: 1.2vmin;
border-radius: 999px;
background:
linear-gradient(90deg, #6a45f5, #d1206f) 0 0 / calc(var(--thumb) / 2 + var(--pos, 0) * (100% - var(--thumb))) 100% no-repeat,
rgb(140 150 220 / 0.2);
}
.controls input[type='range']::-webkit-slider-thumb {
appearance: none;
box-sizing: border-box;
width: var(--thumb);
height: var(--thumb);
margin-top: calc((1.2vmin - var(--thumb)) / 2);
border: 0.8vmin solid transparent;
border-radius: 50%;
background:
linear-gradient(currentColor, currentColor) padding-box,
linear-gradient(135deg, #6a45f5, #d1206f) border-box;
}
.controls input[type='range']::-moz-range-track {
height: 1.2vmin;
border-radius: 999px;
background: rgb(140 150 220 / 0.2);
}
.controls input[type='range']::-moz-range-progress {
height: 1.2vmin;
border-radius: 999px;
background: linear-gradient(90deg, #6a45f5, #d1206f);
}
.controls input[type='range']::-moz-range-thumb {
box-sizing: border-box;
width: var(--thumb);
height: var(--thumb);
border: 0.8vmin solid transparent;
border-radius: 50%;
background:
linear-gradient(currentColor, currentColor) padding-box,
linear-gradient(135deg, #6a45f5, #d1206f) border-box;
}
/* focused from the keyboard, the ring goes round the whole pill, out on the stage like every
other control's: round the thumb it would sit on the tint, where #6a45f5 is under 3:1 */
.controls input[type='range']:focus-visible {
outline: 0;
}
.controls label:has(input[type='range']:focus-visible) {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
/* three sliders share one row: each is shorter than the block's, its track and thumb the same */
.controls input[type='range'] {
width: 14vmin;
}
JS 17 lines
const root = document.querySelector('.play');
const out = root.querySelector('.caption');
const inputs = [...root.querySelectorAll('input[type=range]')];
function update() {
const v = {};
for (const input of inputs) {
v[input.dataset.var] = input.value;
root.style.setProperty('--' + input.dataset.var, input.value + (input.dataset.unit || ''));
// how far along the slider is, 0 to 1: the filled part of its track
input.style.setProperty('--pos', (input.value - input.min) / (input.max - input.min));
}
out.textContent = 'persp ' + v.p + ' · rX ' + v.rx + '° · rY ' + v.ry + '°';
}
root.addEventListener('input', update);
update();