Angle Dial <mv-angle-dial>
Angle input in degrees that reads like CSS (0° up, clockwise): a knob inside a ring of ticks that doubles as the snap grid, with the ticks behind the knob lit and fading with distance.
How it works
Angle input in degrees that reads like CSS (0° up, clockwise): a knob inside a ring of ticks that doubles as the snap grid, with the ticks behind the knob lit and fading with distance. Drag with soft 45° detents or Shift to snap, full slider keyboard, form-associated.
| Category | Forms |
|---|---|
| Type | Web Component (<mv-angle-dial>) |
| Status | stable |
| Keywords | angle, dial, knob, rotation, gradient, inspector, slider, form-associated, editor, ticks |
When to use
- A design tool lets users set a rotation or gradient direction in degrees by dragging around a dial
- An inspector panel needs a compact angle control that snaps to 15 or 45 degree steps
- A settings form must submit a direction or heading as a plain number of degrees
Avoid when
- The value is a generic linear number rather than an angle → use Slider instead
- Users need to type the exact angle more often than drag it → use Scrub Field instead
- The audience is non-technical and a circular control would be unfamiliar in a regular form
Install
node scripts/add.mjs angle-dial --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["angle-dial"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/form.js, core/i18n.js, components/angle-dial/angle-dial.js, components/angle-dial/angle-dial.css.
Usage
Canonical markup, to start from and customize with attributes, data-* and CSS variables:
<div id="angle-demo">
<style>
#angle-demo { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: center; justify-content: center; width: 100%; }
#angle-demo .ad-stage { display: grid; place-items: center; width: 15rem; height: 15rem; border-radius: var(--mv-radius-xl); background: radial-gradient(circle at 1px 1px, color-mix(in oklch, var(--mv-fg) 12%, transparent) 1px, transparent 0) 0 0 / 12px 12px, var(--mv-bg); border: 1px solid var(--mv-border); }
#angle-demo .ad-shape { width: 8rem; height: 8rem; border-radius: 1.5rem; box-shadow: 0 18px 40px -16px oklch(0.4 0.2 260 / .55); }
#angle-demo .ad-panel { display: grid; gap: .6rem; width: 16.5rem; padding: .85rem; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-lg); background: var(--mv-surface); box-shadow: var(--mv-shadow-sm); }
#angle-demo .ad-head { margin: 0; font: 600 .7rem var(--mv-font-sans); letter-spacing: .06em; text-transform: uppercase; color: var(--mv-fg-subtle); }
#angle-demo .ad-row { display: grid; grid-template-columns: 4.75rem auto 1fr; align-items: center; gap: .6rem; font-size: .75rem; color: var(--mv-fg-muted); }
#angle-demo .ad-row mv-scrub-field { width: 100%; }
#angle-demo .ad-big { --mv-angle-dial-size: 3.5rem; }
#angle-demo .ad-hint { margin: .15rem 0 0; font-size: .7rem; line-height: 1.5; color: var(--mv-fg-muted); }
#angle-demo kbd { font: 500 .65rem var(--mv-font-mono); padding: 0 .25rem; border-radius: 3px; background: var(--mv-bg-muted); border: 1px solid var(--mv-border); }
</style>
<div class="ad-stage"><div class="ad-shape" id="angle-demo-shape"></div></div>
<div class="ad-panel" role="group" aria-label="Inspector">
<p class="ad-head">Transform</p>
<div class="ad-row">
<span id="angle-demo-l1">Rotation</span>
<mv-angle-dial id="angle-demo-rot" name="rotation" value="-15" aria-labelledby="angle-demo-l1"></mv-angle-dial>
<mv-scrub-field id="angle-demo-rot-field" aria-label="Rotation in degrees" unit="°" value="345" min="0" max="360" wrap precision="0"></mv-scrub-field>
</div>
<p class="ad-head">Fill</p>
<div class="ad-row">
<span id="angle-demo-l2">Gradient</span>
<mv-angle-dial id="angle-demo-grad" class="ad-big" name="angle" value="135" snap="45" readout aria-labelledby="angle-demo-l2"></mv-angle-dial>
<span></span>
</div>
<p class="ad-hint">Drag the dial or use the arrow keys. <kbd>Shift</kbd> snaps to the ticks, <kbd>Alt</kbd> drags freely, double-click resets.</p>
</div>
</div>
<script type="module">
const $ = (id) => document.getElementById(`angle-demo-${id}`);
const apply = () => {
$("shape").style.rotate = `${$("rot").value}deg`;
$("shape").style.background = `linear-gradient(${$("grad").value}deg, oklch(0.8 0.13 190), oklch(0.6 0.2 265) 55%, oklch(0.68 0.2 340))`;
};
$("rot").addEventListener("mv-input", (e) => { $("rot-field").value = e.detail.value; apply(); });
$("rot-field").addEventListener("mv-input", (e) => { $("rot").value = e.detail.value; apply(); });
$("grad").addEventListener("mv-input", apply);
Promise.all(["mv-angle-dial", "mv-scrub-field"].map((t) => customElements.whenDefined(t))).then(() => requestAnimationFrame(apply));
</script>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
value | number | 0 | Angle in degrees, normalized to [0, 360). Restored on form reset and on double-click. |
name | string | Form field name; the form receives the number of degrees. | |
label | string | Angle | Accessible name. aria-labelledby / aria-label on the element or an external <label> work too. |
step | number | 1 | Arrow-key step (Alt: a tenth of it, when precision allows). |
snap | number | 15 | Snap grid: Shift while dragging or with the arrows, PageUp/PageDown step, and the spacing of the ticks. |
precision | number | 0 | Decimal places kept. |
readout | boolean | Shows the value in the center of the knob (for sizes of about 3rem and up). | |
locale | string | Number formatting locale. Without it (or with auto), follows the page language (nearest lang), else en-US. | |
disabled / readonly / required | boolean | Standard form states. |
Properties
| Name | Type | Description |
|---|---|---|
value | number | Current angle. |
valueAsNumber | number | Same as value. |
defaultValue | number | Value restored on form reset and double-click (the initial value). |
strings | Partial<Record<string, string>> | Overrides for every text: label ("Angle"), valueText ("{value} degrees"). English defaults. |
Methods
| Name | Description |
|---|---|
focus() | Focuses the slider. |
Events
| Name | Description |
|---|---|
mv-input | While dragging and on every key press. detail: { value }. |
mv-change | On release (if the value changed) and on every key press. detail: { value }. |
CSS classes
| Name | Description |
|---|---|
mv-angle-dial-face / -ticks / -tick / -arc / -plate / -needle / -knob / -readout / -badge | Generated parts. -tick[data-major] marks every 90°. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-angle-dial-size | var(--mv-control-sm) | Diameter; ticks, knob and readout scale with it. |
--mv-angle-dial-bg | Background of the tick band. |
Accessibility
The face has role="slider" with aria-valuemin 0, aria-valuemax 359 (the last value before it wraps, finer with precision), aria-valuenow and aria-valuetext “135 degrees” (localizable). Keyboard: →/↑ and ←/↓ by step (wrapping past 0°), Alt+arrow for a tenth of a step, Shift+arrow to the next snap tick, PageUp/PageDown ±snap, Home 0°, End the maximum. Esc while dragging restores the starting angle. Named by label, aria-labelledby/aria-label on the element, or a <label>. Visible focus ring; forced colors use system colors (Highlight for the value); the value bubble briefly shows after a key press; motion is limited to short transitions.