Component 47 of 60
Slider
Selects a value or range along a track.
Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.
fn slider_preview(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = window.use_keyed_state("volume", cx, |_, _| {
gpuicn::slider::SliderState::new()
.min(0.)
.max(100.)
.step(1.)
.default_value(50.)
});
div()
.w(px(280.))
.flex()
.flex_col()
.gap(px(12.))
.child("Volume")
.child(Slider::new(&state).aria_label("Volume"))
}
Installation
After the one-time setup, run the command from your app root. The CLI
writes the modules and keeps src/ui/mod.rs in step.
gpuicn add slider
If you prefer to copy the files by hand, declare them yourself:
// src/ui/mod.rs
pub mod theme;
pub mod slider;
// src/main.rs
mod ui;
Usage
Put this in a view module and return example(window, cx) from its render method.
use crate::ui::slider::*;
use gpui_kit::{App, IntoElement, Window};
fn example(window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state("volume", cx, |_, _cx| {
SliderState::new().min(0.).max(100.).default_value(50.)
});
Slider::new(&state).aria_label("Volume")
}
API reference
pub fn new(state: &Entity<SliderState>) -> Self
Creates a Nova slider using the caller's retained Kit state.
pub fn disabled(mut self, value: bool) -> Self
Disables interaction and applies the disabled appearance.
pub fn aria_label(mut self, value: impl Into<SharedString>) -> Self
Sets the accessible name of the control.
Installed source, 207 lines
//! Single-value slider using Kit state and pointer primitives.
use super::theme::{
UiTheme, apply_style,
primitives::{disabled, focus},
};
use gpui_kit::base::TestSupportExt as _;
pub use gpui_kit::base::slider::{SliderEvent, SliderState, SliderValue};
use gpui_kit::base::{SliderIndicator, SliderThumb, SliderTrack};
use gpui_kit::{
AccessibleAction, App, ElementId, Entity, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, relative,
};
#[derive(IntoElement)]
/// A Nova presentation of retained Kit slider state.
pub struct Slider {
state: Entity<SliderState>,
disabled: bool,
label: Option<SharedString>,
style: StyleRefinement,
}
impl Slider {
/// Creates a Nova slider using the caller's retained Kit state.
pub fn new(state: &Entity<SliderState>) -> Self {
Self {
state: state.clone(),
disabled: false,
label: None,
style: Default::default(),
}
}
/// Disables interaction and applies the disabled appearance.
pub fn disabled(mut self, value: bool) -> Self {
self.disabled = value;
self
}
/// Sets the accessible name of the control.
pub fn aria_label(mut self, value: impl Into<SharedString>) -> Self {
self.label = Some(value.into());
self
}
}
impl Styled for Slider {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Slider {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = UiTheme::read(cx).clone();
let colors = theme.colors;
let state = self.state.read(cx);
assert!(
state.value().is_single(),
"Slider expects single-value state"
);
let (min, max, step, value) = (
state.min_value(),
state.max_value(),
state.step_value(),
state.value().end(),
);
assert!(
min.is_finite()
&& max.is_finite()
&& min < max
&& step.is_finite()
&& step > 0.
&& value.is_finite()
);
let percent = state.percentage().end;
let focus = window
.use_keyed_state(
ElementId::from(("slider-focus", self.state.entity_id())),
cx,
|_, cx| cx.focus_handle(),
)
.read(cx)
.clone()
.tab_stop(!self.disabled);
let focused = focus.is_focused(window);
let click_focus = focus.clone();
let thumb_focus = focus.clone();
let input_state = self.state.clone();
let increment = self.state.clone();
let decrement = self.state.clone();
let set_value = self.state.clone();
let release = self.state.clone();
let release_out = self.state.clone();
let thumb = SliderThumb::new(&self.state)
.disabled(self.disabled)
.absolute()
.left(relative(percent))
.ml(-theme.space(1.5))
.top(-theme.space(1.))
.size(theme.space(3.))
.rounded_full()
.border_1()
.border_color(colors.primary)
.bg(colors.background)
.map(|thumb| focus::shown(thumb, &theme, focused && !self.disabled))
.when(!self.disabled, |t| {
t.on_mouse_down(MouseButton::Left, move |_, window, cx| {
thumb_focus.focus(window, cx)
})
});
let root = div()
.id(("slider", self.state.entity_id()))
.test_support()
.role(Role::Slider)
.aria_numeric_value(value as f64)
.aria_min_numeric_value(min as f64)
.aria_max_numeric_value(max as f64)
.aria_numeric_value_step(step as f64)
.aria_orientation(gpui_kit::Orientation::Horizontal)
.when_some(self.label, |root, label| root.aria_label(label))
.relative()
.w_full()
.h(theme.space(5.))
.flex()
.items_center()
.when(self.disabled, disabled::style)
.when(!self.disabled, |root| {
root.track_focus(&focus)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
click_focus.focus(window, cx)
})
.on_key_down(move |event, window, cx| {
if event.keystroke.modifiers.modified() {
return;
}
let state = input_state.read(cx);
let value = match event.keystroke.key.as_str() {
"right" | "up" => state.value().end() + state.step_value(),
"left" | "down" => state.value().end() - state.step_value(),
"pageup" => state.value().end() + state.step_value() * 10.,
"pagedown" => state.value().end() - state.step_value() * 10.,
"home" => state.min_value(),
"end" => state.max_value(),
_ => return,
};
update_value(&input_state, value, window, cx);
cx.stop_propagation();
})
.on_a11y_action(AccessibleAction::Increment, move |_, window, cx| {
let state = increment.read(cx);
let value = state.value().end() + state.step_value();
update_value(&increment, value, window, cx);
})
.on_a11y_action(AccessibleAction::Decrement, move |_, window, cx| {
let state = decrement.read(cx);
let value = state.value().end() - state.step_value();
update_value(&decrement, value, window, cx);
})
.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
if let Some(gpui_kit::accesskit::ActionData::NumericValue(value)) = data {
update_value(&set_value, *value as f32, window, cx);
}
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| {
release.update(cx, |state, cx| state.handle_release(cx))
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
release_out.update(cx, |state, cx| state.handle_release(cx))
})
})
.child(
SliderTrack::new(&self.state)
.disabled(self.disabled)
.relative()
.w_full()
.h(theme.space(1.))
.child(
SliderIndicator::new(&self.state)
.relative()
.w_full()
.h_full()
.rounded_full()
.bg(colors.muted)
.child(
div()
.h_full()
.w(relative(percent))
.rounded_full()
.bg(colors.primary),
),
)
.child(thumb),
);
apply_style(root, &self.style)
}
}
fn update_value(state: &Entity<SliderState>, value: f32, window: &mut Window, cx: &mut App) {
if !value.is_finite() {
return;
}
state.update(cx, |state, cx| {
let min = state.min_value();
let value = (min + ((value - min) / state.step_value()).round() * state.step_value())
.clamp(min, state.max_value());
state.set_value(value, window, cx);
state.handle_release(cx);
});
}
Platform notes
Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation:
crates/ui/registry/slider/slider.rs, using GPUI Kit 0.6.4.
Slider takes a retained Kit SliderState configured with range, step and initial value. Kit owns pointer and keyboard changes, including range thumbs; gpuicn supplies the Nova track, range and thumb styles.
The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, exact exit animations and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.