CSS + JS Data & tools #controls

Perspective playground in CSS + JavaScript

Sliders feed perspective and rotation straight into CSS, with the current values shown live.

  • 163 lines of CSS
  • 12 lines of HTML
  • 17 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. perspective is the distance from your eye to the z=0 plane. Against this 120-unit box, the slider’s low end (150 units) distorts wildly and its high end (1200) looks almost flat.
  2. It belongs on the parent of the thing you rotate.
  3. The sliders just write three custom properties; the box’s transform reads them. Each also writes --pos on itself, how far along it is from 0 to 1, which is where its filled track ends.
  4. The perspective slider writes a plain number, not a length: the model multiplies it by its own base unit, so 400 here is 400 next to a 120-unit box — the same picture at any canvas size.
  5. Try it: set rotateY to 60° and sweep perspective from one end to the other.

Key techniques

  • perspective
  • rotateX / rotateY
  • range inputs → custom properties

Copy-paste code

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();
Standalone snippet · plain CSS, no build step, no dependencies.