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" export function AngleSlider() { const service = useMachine(angleSlider.machine, { id: "1" }) 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()}> {[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> <!-- ... -->
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; }>The ids of the elements in the machine. Useful for composition.stepnumberThe step value for the slider.valuenumberThe value of the slider.defaultValuenumberThe initial value of the slider. Use when you don't need to control the value of the slider.onValueChange(details: ValueChangeDetails) => voidThe callback function for when the value changes.onValueChangeEnd(details: ValueChangeDetails) => voidThe callback function for when the value changes ends.disabledbooleanWhether the slider is disabled.readOnlybooleanWhether the slider is read-only.invalidbooleanWhether the slider is invalid.namestringThe name of the slider. Useful for form submission.aria-labelstringThe accessible label for the slider thumb.aria-labelledbystringThe id of the element that labels the slider thumb.dir"ltr" | "rtl"The document's text/writing direction.idstringThe unique identifier of the machine.getRootNode() => ShadowRoot | Node | DocumentA 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.