26 CSS Circular Menus08 / 26

CSS + JSMIT licensed

Rotary Control Dial Interface

A hi-fi–grade rotary knob: drag around it, scroll over it, or use arrow keys, and a conic-gradient arc sweeps to show the value while the tick marks light up behind it. Under the hood it's a proper ARIA slider, so it reports its value to assistive tech just like a native range input.

Published Updated

Live Demo
Try it

The code

<section class="ccm-08" aria-label="Rotary dial control demo">
  <div class="ccm-08__stage">
    <div class="ccm-08__deck">
      <div class="ccm-08__ticks" aria-hidden="true"></div>
      <div class="ccm-08__knob" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="62" aria-valuetext="62 percent">
        <span class="ccm-08__cap"><i class="ccm-08__pip"></i></span>
      </div>
      <p class="ccm-08__readout"><output class="ccm-08__val">62</output><span>%</span></p>
      <p class="ccm-08__label">volume</p>
    </div>
    <p class="ccm-08__hint">Drag around the knob · scroll · or arrow keys</p>
  </div>
</section>
.ccm-08,
.ccm-08 *,
.ccm-08 *::before,
.ccm-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ccm-08 {
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(180deg,oklch(0.23 0.015 160),oklch(0.17 0.012 170));
  --brand: oklch(0.75 0.17 145);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: oklch(0.9 0.01 150);
}

.ccm-08__stage {
  width: 100%;
  height: 100vh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 26px;
  border-radius: 20px;
  background: linear-gradient(180deg,oklch(0.23 0.015 160),oklch(0.17 0.012 170));
  overflow: hidden;
}

.ccm-08__deck {
  position: relative;
  width: 240px;
  height: 240px;
  display: grid;
  place-items: center;
}

.ccm-08__ticks {
  position: absolute;
  inset: 0;
}

.ccm-08__ticks i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 2px;
  height: 9px;
  margin-left: -1px;
  border-radius: 2px;
  background: oklch(0.45 0.02 160);
  transform: rotate(var(--t)) translateY(-116px);
  transition: background .2s;
}

.ccm-08__ticks i.is-lit {
  background: var(--brand);
  box-shadow: 0 0 8px oklch(0.75 0.17 145 / .7);
}

.ccm-08__knob {
  position: relative;
  width: 176px;
  height: 176px;
  border-radius: 50%;
  cursor: grab;
  touch-action: none;
  background: conic-gradient(from 225deg,var(--brand) calc(var(--val,62) * 270deg / 100),oklch(0.32 0.015 160 / .6) calc(var(--val,62) * 270deg / 100) 270deg,transparent 270deg);
  display: grid;
  place-items: center;
  -webkit-user-select: none;
  user-select: none;
}

.ccm-08__knob:active {
  cursor: grabbing;
}

.ccm-08__knob:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 6px;
}

.ccm-08__cap {
  position: relative;
  width: 148px;
  height: 148px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 28%,oklch(0.36 0.015 160),oklch(0.24 0.012 165) 70%);
  box-shadow: 0 14px 30px -10px oklch(0 0 0 / .7),inset 0 1px 0 oklch(1 0 0 / .14),inset 0 -6px 14px oklch(0 0 0 / .4);
  rotate: calc(135deg + var(--val,62) * 2.7deg + 90deg);
}

.ccm-08__pip {
  position: absolute;
  left: 50%;
  top: 12px;
  width: 10px;
  height: 10px;
  margin-left: -5px;
  border-radius: 50%;
  background: var(--brand);
  box-shadow: 0 0 12px oklch(0.75 0.17 145 / .9);
}

.ccm-08__readout {
  position: absolute;
  bottom: -8px;
  display: flex;
  align-items: baseline;
  gap: 2px;
  font-variant-numeric: tabular-nums;
}

.ccm-08__val {
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -.02em;
}

.ccm-08__readout span {
  font-size: 14px;
  color: oklch(0.6 0.03 155);
}

.ccm-08__label {
  position: absolute;
  top: -4px;
  font-size: 11px;
  letter-spacing: .3em;
  text-transform: uppercase;
  color: oklch(0.6 0.03 155);
}

.ccm-08__hint {
  font-size: 13px;
  color: oklch(0.6 0.03 155);
}

@media (prefers-reduced-motion: reduce) {
  .ccm-08 * {
    transition-duration: .01s !important;
  }
}
(() => {
  const root = document.querySelector('.ccm-08');
  const knob = root.querySelector('.ccm-08__knob');
  const out = root.querySelector('.ccm-08__val');
  const ticks = root.querySelector('.ccm-08__ticks');
  for (let t = 0; t <= 27; t++) {
    const i = document.createElement('i');
    i.style.setProperty('--t', (135 + t * 10 + 90) + 'deg');
    ticks.append(i);
  }
  const tickEls = [...ticks.children];
  let val = 62;
  const setVal = (v) => {
    val = Math.max(0, Math.min(100, Math.round(v)));
    knob.style.setProperty('--val', val);
    knob.setAttribute('aria-valuenow', val);
    knob.setAttribute('aria-valuetext', val + ' percent');
    out.textContent = val;
    tickEls.forEach((el, k) => el.classList.toggle('is-lit', k / 27 * 100 <= val));
  };
  const fromPointer = (e) => {
    const r = knob.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    let deg = Math.atan2(dy, dx) * 180 / Math.PI; // -180..180, 0 = 3 o'clock
    deg = (deg - 135 + 360) % 360;                // 0 at sweep start
    if (deg > 270) deg = deg < 315 ? 270 : 0;     // snap the dead zone to nearest end
    setVal(deg / 270 * 100);
  };
  knob.addEventListener('pointerdown', (e) => {
    knob.setPointerCapture(e.pointerId); fromPointer(e);
    const move = (ev) => fromPointer(ev);
    knob.addEventListener('pointermove', move);
    knob.addEventListener('pointerup', () => knob.removeEventListener('pointermove', move), { once: true });
  });
  knob.addEventListener('wheel', (e) => { e.preventDefault(); setVal(val + (e.deltaY < 0 ? 2 : -2)); }, { passive: false });
  knob.addEventListener('keydown', (e) => {
    const map = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 };
    if (e.key in map) { e.preventDefault(); setVal(val + map[e.key]); }
    if (e.key === 'Home') setVal(0);
    if (e.key === 'End') setVal(100);
  });
  setVal(62);
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Circular Menu from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Rotary Control Dial Interface
Source: https://codefronts.com/navigation/css-circular-menus/tactile-dial/

A hi-fi–grade rotary knob: drag around it, scroll over it, or use arrow keys, and a conic-gradient arc sweeps to show the value while the tick marks light up behind it. Under the hood it's a proper ARIA slider, so it reports its value to assistive tech just like a native range input.
## HTML
```html
<section class="ccm-08" aria-label="Rotary dial control demo">
  <div class="ccm-08__stage">
    <div class="ccm-08__deck">
      <div class="ccm-08__ticks" aria-hidden="true"></div>
      <div class="ccm-08__knob" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="62" aria-valuetext="62 percent">
        <span class="ccm-08__cap"><i class="ccm-08__pip"></i></span>
      </div>
      <p class="ccm-08__readout"><output class="ccm-08__val">62</output><span>%</span></p>
      <p class="ccm-08__label">volume</p>
    </div>
    <p class="ccm-08__hint">Drag around the knob · scroll · or arrow keys</p>
  </div>
</section>
```
## CSS
```css
.ccm-08,
.ccm-08 *,
.ccm-08 *::before,
.ccm-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ccm-08 {
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(180deg,oklch(0.23 0.015 160),oklch(0.17 0.012 170));
  --brand: oklch(0.75 0.17 145);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: oklch(0.9 0.01 150);
}

.ccm-08__stage {
  width: 100%;
  height: 100vh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 26px;
  border-radius: 20px;
  background: linear-gradient(180deg,oklch(0.23 0.015 160),oklch(0.17 0.012 170));
  overflow: hidden;
}

.ccm-08__deck {
  position: relative;
  width: 240px;
  height: 240px;
  display: grid;
  place-items: center;
}

.ccm-08__ticks {
  position: absolute;
  inset: 0;
}

.ccm-08__ticks i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 2px;
  height: 9px;
  margin-left: -1px;
  border-radius: 2px;
  background: oklch(0.45 0.02 160);
  transform: rotate(var(--t)) translateY(-116px);
  transition: background .2s;
}

.ccm-08__ticks i.is-lit {
  background: var(--brand);
  box-shadow: 0 0 8px oklch(0.75 0.17 145 / .7);
}

.ccm-08__knob {
  position: relative;
  width: 176px;
  height: 176px;
  border-radius: 50%;
  cursor: grab;
  touch-action: none;
  background: conic-gradient(from 225deg,var(--brand) calc(var(--val,62) * 270deg / 100),oklch(0.32 0.015 160 / .6) calc(var(--val,62) * 270deg / 100) 270deg,transparent 270deg);
  display: grid;
  place-items: center;
  -webkit-user-select: none;
  user-select: none;
}

.ccm-08__knob:active {
  cursor: grabbing;
}

.ccm-08__knob:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 6px;
}

.ccm-08__cap {
  position: relative;
  width: 148px;
  height: 148px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 28%,oklch(0.36 0.015 160),oklch(0.24 0.012 165) 70%);
  box-shadow: 0 14px 30px -10px oklch(0 0 0 / .7),inset 0 1px 0 oklch(1 0 0 / .14),inset 0 -6px 14px oklch(0 0 0 / .4);
  rotate: calc(135deg + var(--val,62) * 2.7deg + 90deg);
}

.ccm-08__pip {
  position: absolute;
  left: 50%;
  top: 12px;
  width: 10px;
  height: 10px;
  margin-left: -5px;
  border-radius: 50%;
  background: var(--brand);
  box-shadow: 0 0 12px oklch(0.75 0.17 145 / .9);
}

.ccm-08__readout {
  position: absolute;
  bottom: -8px;
  display: flex;
  align-items: baseline;
  gap: 2px;
  font-variant-numeric: tabular-nums;
}

.ccm-08__val {
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -.02em;
}

.ccm-08__readout span {
  font-size: 14px;
  color: oklch(0.6 0.03 155);
}

.ccm-08__label {
  position: absolute;
  top: -4px;
  font-size: 11px;
  letter-spacing: .3em;
  text-transform: uppercase;
  color: oklch(0.6 0.03 155);
}

.ccm-08__hint {
  font-size: 13px;
  color: oklch(0.6 0.03 155);
}

@media (prefers-reduced-motion: reduce) {
  .ccm-08 * {
    transition-duration: .01s !important;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('.ccm-08');
  const knob = root.querySelector('.ccm-08__knob');
  const out = root.querySelector('.ccm-08__val');
  const ticks = root.querySelector('.ccm-08__ticks');
  for (let t = 0; t <= 27; t++) {
    const i = document.createElement('i');
    i.style.setProperty('--t', (135 + t * 10 + 90) + 'deg');
    ticks.append(i);
  }
  const tickEls = [...ticks.children];
  let val = 62;
  const setVal = (v) => {
    val = Math.max(0, Math.min(100, Math.round(v)));
    knob.style.setProperty('--val', val);
    knob.setAttribute('aria-valuenow', val);
    knob.setAttribute('aria-valuetext', val + ' percent');
    out.textContent = val;
    tickEls.forEach((el, k) => el.classList.toggle('is-lit', k / 27 * 100 <= val));
  };
  const fromPointer = (e) => {
    const r = knob.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    let deg = Math.atan2(dy, dx) * 180 / Math.PI; // -180..180, 0 = 3 o'clock
    deg = (deg - 135 + 360) % 360;                // 0 at sweep start
    if (deg > 270) deg = deg < 315 ? 270 : 0;     // snap the dead zone to nearest end
    setVal(deg / 270 * 100);
  };
  knob.addEventListener('pointerdown', (e) => {
    knob.setPointerCapture(e.pointerId); fromPointer(e);
    const move = (ev) => fromPointer(ev);
    knob.addEventListener('pointermove', move);
    knob.addEventListener('pointerup', () => knob.removeEventListener('pointermove', move), { once: true });
  });
  knob.addEventListener('wheel', (e) => { e.preventDefault(); setVal(val + (e.deltaY < 0 ? 2 : -2)); }, { passive: false });
  knob.addEventListener('keydown', (e) => {
    const map = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 };
    if (e.key in map) { e.preventDefault(); setVal(val + map[e.key]); }
    if (e.key === 'Home') setVal(0);
    if (e.key === 'End') setVal(100);
  });
  setVal(62);
})();
```

How this works

The value arc is one background, no SVG:

background: conic-gradient(from 225deg,
  var(--brand) calc(var(--val) * 270deg / 100),
  oklch(0.3 0.02 260 / .5) 0);

Starting from 225deg (the 7:30 position) and capping at 270° gives the classic 7-o'clock-to-5-o'clock sweep. The pointer drag maps Math.atan2(dy, dx) of the cursor relative to the knob center onto that 270° range, clamped at the gap.

Semantics: the knob is a focusable role="slider" with aria-valuemin/max/now kept in sync, plus ←→↑↓, Home/End and wheel support — the full native-range keymap.

Make it yours

  • Sweep range: change the 270 in both the gradient and the JS mapping.
  • Detents: round the value to steps of 5 in setVal() for a clicky feel.
  • The readout is a live region — swap % for dB, °C, or BPM.
  • Tick count is one loop constant in the JS bootstrap.

Gotchas — read before shipping

  • atan2 returns -180°..180° — normalize before mapping to the sweep or the knob jumps at the wrap point.
  • Set touch-action:none on the knob or mobile drags scroll the page instead.
  • role="slider" without arrow-key handling is an accessibility violation, not an enhancement — implement the keymap.

Browser support

ChromeSafariFirefoxEdge
111+15.4+113+111+

Floor set by oklch() as this demo is written. The core technique itself goes back further — Chrome any.

conic-gradient (2020 everywhere) is the newest CSS. Pointer Events cover mouse/touch/pen in one code path in every evergreen.

Search CodeFronts

Loading…