Angle Slider
An angle slider is a circular dial that allows users to select an angle, typically in degrees, within a 360° range. It provides an intuitive way to control rotations or orientations, offering accessibility features.
Features
- Fully managed keyboard navigation
- Supports touch or click on the track to update value
- Supports right-to-left direction
Installation
Install the angle slider package:
npm install @zag-js/angle-slider @zag-js/react # or yarn add @zag-js/angle-slider @zag-js/react
npm install @zag-js/angle-slider @zag-js/solid # or yarn add @zag-js/angle-slider @zag-js/solid
npm install @zag-js/angle-slider @zag-js/vue # or yarn add @zag-js/angle-slider @zag-js/vue
npm install @zag-js/angle-slider @zag-js/svelte # or yarn add @zag-js/angle-slider @zag-js/svelte
Anatomy
To set up the angle slider correctly, you'll need to understand its anatomy and how we name its parts.
Each part includes a
data-partattribute to help identify them in the DOM.
Usage
Import the angle-slider package:
import * as angleSlider from "@zag-js/angle-slider"
The angle slider package exports two key functions:
machine- State machine logic.connect- Maps machine state to JSX props and event handlers.
Pass a unique
idtouseMachineso generated element ids stay predictable.
Then use the framework integration helpers:
import * as angleSlider from "@zag-js/angle-slider" import { normalizeProps, useMachine } from "@zag-js/react" import { useId } from "react" export function AngleSlider() { const service = useMachine(angleSlider.machine, { id: useId() }) const api = angleSlider.connect(service, normalizeProps) return ( <div {...api.getRootProps()}> <label {...api.getLabelProps()}>Wind direction</label> <div {...api.getControlProps()}> <div {...api.getThumbProps()}></div> <div {...api.getMarkerGroupProps()}> {[0, 45, 90, 135, 180, 225, 270, 315].map((value) => ( <div key={value} {...api.getMarkerProps({ value })}></div> ))} </div> </div> <div {...api.getValueTextProps()}>{api.value} degrees</div> <input {...api.getHiddenInputProps()} /> </div> ) }
import * as angleSlider from "@zag-js/angle-slider" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId, Index } from "solid-js" export function AngleSlider() { const service = useMachine(angleSlider.machine, { id: createUniqueId() }) const api = createMemo(() => angleSlider.connect(service, normalizeProps)) return ( <div {...api().getRootProps()}> <label {...api().getLabelProps()}> Angle Slider: <div {...api().getValueTextProps()}>{api().valueAsDegree}</div> </label> <div {...api().getControlProps()}> <div {...api().getThumbProps()}></div> <div {...api().getMarkerGroupProps()}> <Index each={[0, 45, 90, 135, 180, 225, 270, 315]}> {(value) => ( <div {...api().getMarkerProps({ value: value() })}></div> )} </Index> </div> </div> <input {...api().getHiddenInputProps()} /> </div> ) }
<script setup lang="ts"> import * as angleSlider from "@zag-js/angle-slider" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed } from "vue" const service = useMachine(angleSlider.machine, { id: "1" }) const api = computed(() => angleSlider.connect(service, normalizeProps)) </script> <template> <div v-bind="api.getRootProps()"> <label v-bind="api.getLabelProps()"> Angle Slider: <div v-bind="api.getValueTextProps()">{{ api.valueAsDegree }}</div> </label> <div v-bind="api.getControlProps()"> <div v-bind="api.getThumbProps()"></div> <div v-bind="api.getMarkerGroupProps()"> <div v-for="value in [0, 45, 90, 135, 180, 225, 270, 315]" :key="value" v-bind="api.getMarkerProps({ value })" ></div> </div> </div> <input v-bind="api.getHiddenInputProps()" /> </div> </template>
<script lang="ts"> import * as angleSlider from "@zag-js/angle-slider" import { normalizeProps, useMachine } from "@zag-js/svelte" const id = $props.id() const service = useMachine(angleSlider.machine, ({ id })) const api = $derived(angleSlider.connect(service, normalizeProps)) </script> <div {...api.getRootProps()}> <label {...api.getLabelProps()}> Angle Slider: <div {...api.getValueTextProps()}>{api.valueAsDegree}</div> </label> <div {...api.getControlProps()}> <div {...api.getThumbProps()}></div> <div {...api.getMarkerGroupProps()}> {#each [0, 45, 90, 135, 180, 225, 270, 315] as value} <div {...api.getMarkerProps({ value })}></div> {/each} </div> </div> <input {...api.getHiddenInputProps()} /> </div>
Setting the initial value
Set defaultValue to define the initial slider value.
const service = useMachine(angleSlider.machine, { defaultValue: 45, })
Controlled angle slider
Use value and onValueChange to control the value externally.
import { useState } from "react" export function ControlledAngleSlider() { const [value, setValue] = useState(45) const service = useMachine(angleSlider.machine, { value, onValueChange(details) { setValue(details.value) }, }) return ( // ... ) }
import { createSignal } from "solid-js" export function ControlledAngleSlider() { const [value, setValue] = createSignal(45) const service = useMachine(angleSlider.machine, { get value() { return value() }, onValueChange(details) { setValue(details.value) }, }) return ( // ... ) }
<script setup> import { ref } from "vue" const valueRef = ref(45) const service = useMachine(angleSlider.machine, { get value() { return valueRef.value }, onValueChange(details) { valueRef.value = details.value }, }) </script>
<script lang="ts"> let value = $state(45) const service = useMachine(angleSlider.machine, { get value() { return value }, onValueChange(details) { value = details.value }, }) </script>
Setting the value's granularity
By default, step is 1, so values move in whole-number increments. Set step
to control granularity.
For example, set step to 0.01 for two-decimal precision:
const service = useMachine(angleSlider.machine, { step: 0.01, })
Listening for changes
When the angle slider value changes, the onValueChange and onValueChangeEnd
callbacks are invoked.
const service = useMachine(angleSlider.machine, { onValueChange(details) { console.log("value:", details.value) console.log("as degree:", details.valueAsDegree) }, onValueChangeEnd(details) { console.log("final value:", details.value) }, })
Read-only mode
Set readOnly to prevent updates while preserving focus and form semantics.
const service = useMachine(angleSlider.machine, { readOnly: true, })
Usage in forms
To submit the value with a form:
- Set
nameon the machine. - Render the hidden input from
api.getHiddenInputProps().
const service = useMachine(angleSlider.machine, { name: "wind-direction", })
Labeling the thumb for assistive tech
Use aria-label or aria-labelledby when you need custom labeling.
const service = useMachine(angleSlider.machine, { "aria-label": "Wind direction", })
Using angle slider marks
To show marks or ticks along the angle slider track, use the exposed
api.getMarkerProps() method to position the angle slider marks at desired
angles.
//... <div {...api.getRootProps()}> <label {...api.getLabelProps()}>Wind direction</label> <div {...api.getControlProps()}> <div {...api.getThumbProps()}></div> <div {...api.getMarkerGroupProps()}> {[0, 45, 90, 135, 180, 225, 270, 315].map((value) => ( <div key={value} {...api.getMarkerProps({ value })}></div> ))} </div> </div> <div {...api.getValueTextProps()}>{api.value} degrees</div> <input {...api.getHiddenInputProps()} /> </div> //...
//... <div {...api().getRootProps()}> <label {...api().getLabelProps()}>Wind direction</label> <div {...api().getControlProps()}> <div {...api().getThumbProps()}></div> <div {...api().getMarkerGroupProps()}> {[0, 45, 90, 135, 180, 225, 270, 315].map((value) => ( <div key={value} {...api().getMarkerProps({ value })}></div> ))} </div> </div> <div {...api().getValueTextProps()}>{api().value} degrees</div> <input {...api().getHiddenInputProps()} /> </div> //...
//... <div v-bind="api.getRootProps()"> <label v-bind="api.getLabelProps()"> Angle Slider: <div v-bind="api.getValueTextProps()">{{ api.valueAsDegree }}</div> </label> <div v-bind="api.getControlProps()"> <div v-bind="api.getThumbProps()"></div> <div v-bind="api.getMarkerGroupProps()"> <div v-for="value in [0, 45, 90, 135, 180, 225, 270, 315]" :key="value" v-bind="api.getMarkerProps({ value })" ></div> </div> </div> <input v-bind="api.getHiddenInputProps()" /> </div> //...
<!-- ... --> <div {...api.getRootProps()}> <label {...api.getLabelProps()}>Wind direction</label> <div {...api.getControlProps()}> <div {...api.getThumbProps()}></div> <div {...api.getMarkerGroupProps()}> {#each [0, 45, 90, 135, 180, 225, 270, 315] as value} <div {...api.getMarkerProps({ value })}></div> {/each} </div> </div> <div {...api.getValueTextProps()}>{api.value} degrees</div> <input {...api.getHiddenInputProps()} /> </div> <!-- ... -->
Styling guide
Each part includes a data-part attribute you can target in CSS.
Disabled State
When the angle slider is disabled, the data-disabled attribute is added to the
root, label, control, thumb and marker.
[data-part="root"][data-disabled] { /* styles for root disabled state */ } [data-part="label"][data-disabled] { /* styles for label disabled state */ } [data-part="control"][data-disabled] { /* styles for control disabled state */ } [data-part="thumb"][data-disabled] { /* styles for thumb disabled state */ } [data-part="range"][data-disabled] { /* styles for thumb disabled state */ }
Invalid State
When the slider is invalid, the data-invalid attribute is added to the root,
track, range, label, and thumb parts.
[data-part="root"][data-invalid] { /* styles for root invalid state */ } [data-part="label"][data-invalid] { /* styles for label invalid state */ } [data-part="control"][data-invalid] { /* styles for control invalid state */ } [data-part="valueText"][data-invalid] { /* styles for output invalid state */ } [data-part="thumb"][data-invalid] { /* styles for thumb invalid state */ } [data-part="marker"][data-invalid] { /* styles for marker invalid state */ }
Styling the markers
[data-part="marker"][data-state="(at|under|over)-value"] { /* styles for when the value exceeds the marker's value */ }
Methods and Properties
Machine Context
The angle slider machine exposes the following context properties:
idsPartial<{ root: string; thumb: string; hiddenInput: string; control: string; valueText: string; label: string; }> | undefinedThe ids of the elements in the machine. Useful for composition.stepnumber | undefinedThe step value for the slider.valuenumber | undefinedThe value of the slider.defaultValuenumber | undefinedThe initial value of the slider. Use when you don't need to control the value of the slider.onValueChange((details: ValueChangeDetails) => void) | undefinedThe callback function for when the value changes.onValueChangeEnd((details: ValueChangeDetails) => void) | undefinedThe callback function for when the value changes ends.disabledboolean | undefinedWhether the slider is disabled.readOnlyboolean | undefinedWhether the slider is read-only.invalidboolean | undefinedWhether the slider is invalid.namestring | undefinedThe name of the slider. Useful for form submission.aria-labelstring | undefinedThe accessible label for the slider thumb.aria-labelledbystring | undefinedThe id of the element that labels the slider thumb.dir"ltr" | "rtl" | undefinedThe document's text/writing direction.idstringThe unique identifier of the machine.getRootNode(() => ShadowRoot | Node | Document) | undefinedA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.
Machine API
The angle slider api exposes the following methods:
valuenumberThe current value of the angle slidervalueAsDegreestringThe current value as a degree stringsetValue(value: number) => voidSets the value of the angle sliderdraggingbooleanWhether the slider is being dragged.
Data Attributes
CSS Variables
Keyboard Interactions
- ArrowRightIncrements the angle slider based on defined step
- ArrowLeftDecrements the angle slider based on defined step
- ArrowUpDecreases the value by the step amount.
- ArrowDownIncreases the value by the step amount.
- Shift + ArrowUpDecreases the value by a larger step
- Shift + ArrowDownIncreases the value by a larger step
- HomeSets the value to 0 degrees.
- EndSets the value to 360 degrees.