Slider
A native range input with typed styling, labels, markers, and form state.
With markers
The browser-owned range input remains the accessible and submitted control.
Disabled
Form-control state is projected to every relevant slider part.
FreeSlider
Native range input with labels and markers.
Install with the Proa CLI
$ proa ui add sliderInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Slider recipe — typed root orientation and stable compound-part slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Slider layout orientation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum SliderOrientation {
#[default]
Horizontal,
Vertical,
}
impl SliderOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Self::Horizontal => "horizontal",
Slider source
Browse and copy the reviewed source included with this Free component.
controlled.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! RSJS-controlled single-value Slider.
use super::{SliderMarker, SliderOrientation};
use crate::{FormControlIds, FormControlState, PartId};
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteBuf, WriteError};
use rsjs::Handler;
/// Controlled single-value Slider.
///
/// The native range input owns pointer and keyboard behavior. RSJS mirrors its
/// value into the value text and the CSS range position, then reports a numeric
/// value to the parent-owned callback.
pub struct ControlledSlider<'a> {
pub label: &'a str,
pub min: f64,
pub max: f64,
pub step: f64,
pub value: f64,
pub orientation: SliderOrientation,
pub state: FormControlState,
pub markers: &'a [SliderMarker<'a>],
pub id: Option<&'a str>,
pub name: Option<&'a str>,
pub aria_describedby: Option<&'a str>,
pub class: Option<&'a str>,
pub on_change: Handler<f64>,
}
impl<'a> ControlledSlider<'a> {
pub const fn new(label: &'a str, value: f64, on_change: Handler<f64>) -> Self {
Self {
label,
min: 0.0,
max: 100.0,
step: 1.0,
value,
orientation: SliderOrientation::Horizontal,
state: FormControlState::enabled(),
markers: &[],
id: None,
name: None,
aria_describedby: None,
class: None,
on_change,
}
}
}
#[rsjs::rsjs(component, client)]
impl<'a, B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for ControlledSlider<'a> {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let values = ::proa_ui::components::slider::resolve_slider_values(
self.min, self.max, self.step, self.value,
);
let value = rsjs::signal::<f64>(values.value);
let min = rsjs::signal::<f64>(values.min);
let max = rsjs::signal::<f64>(values.max);
let style = ::proa_ui::components::slider::SLIDER_RECIPE.resolve(
::proa_ui::components::slider::SliderRecipeProps {
orientation: Some(self.orientation),
},
);
let ids = FormControlIds::new(self.id);
let control_id = ids.control();
let label_id = ids.label();
let value_text_id = PartId::new(self.id, "value-text");
let control = rsjs::node_ref::<rsjs::HtmlElement>();
// `bind_value` is installed before event handlers by the RSJS emitter,
// so this handler reads the just-edited DOM value. Multiplication by
// `1.0` preserves the public f64 callback even though DOM values are
// strings at the browser boundary.
let update_on_input = rsjs::event_handler(|_: rsjs::InputEvent<rsjs::elem::Input>| {
let numeric_value = value.get() * 1.0;
let position = ((numeric_value - min.get()) / (max.get() - min.get())) * 100.0;
control.set_style_property("--slider-position", position);
control.set_attribute("data-position", position);
self.on_change.call(numeric_value);
});
proa_macros::html_sync! {
<div
data_controlled_slider
data_slider
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="root"
data_slot="slider"
data_orientation={rsjs::server_attr(style.orientation().as_str())}
data_disabled={rsjs::server_attr(if self.state.disabled { "true" } else { "false" })}
data_invalid={rsjs::server_attr(if self.state.invalid { "true" } else { "false" })}
data_read_only={rsjs::server_attr(if self.state.read_only { "true" } else { "false" })}
data_required={rsjs::server_attr(if self.state.required { "true" } else { "false" })}
id={rsjs::server_attr(self.id)}
class={rsjs::server_attr(proa_macros::text!("{} {}", style, self.class))}
>
<label
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="label"
data_slot="slider-label"
id={label_id}
for={control_id}
class={::proa_ui::components::slider::SLIDER_RECIPE.label}
>
<span>{self.label}</span>
<output
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="value-text"
data_slot="slider-value-text"
id={value_text_id}
for={control_id}
class={::proa_ui::components::slider::SLIDER_RECIPE.value_text}
>
{value.get() * 1.0}
</output>
<span
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="control"
data_slot="slider-control"
data_position={values.position}
class={::proa_ui::components::slider::SLIDER_RECIPE.control}
style={rsjs::server_attr(proa_core::style_ok(::proa_ui::components::slider::SliderPositionStyle::value(values.position)))}
ref={control}
>
<span
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="track"
data_slot="slider-track"
aria_hidden="true"
class={::proa_ui::components::slider::SLIDER_RECIPE.track}
>
<span
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="range"
data_slot="slider-range"
class={::proa_ui::components::slider::SLIDER_RECIPE.range}
></span>
</span>
<span
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="thumb"
data_slot="slider-thumb"
aria_hidden="true"
class={::proa_ui::components::slider::SLIDER_RECIPE.thumb}
></span>
<span
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="marker-group"
data_slot="slider-marker-group"
aria_hidden="true"
class={::proa_ui::components::slider::SLIDER_RECIPE.marker_group}
>
{for marker in self.markers {
let marker_position = values.position_for(marker.value);
proa_macros::html_sync! {
<span
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="marker"
data_slot="slider-marker"
data_value={rsjs::server_attr(marker.value)}
data_position={rsjs::server_attr(marker_position)}
class={::proa_ui::components::slider::SLIDER_RECIPE.marker}
style={rsjs::server_attr(proa_core::style_ok(::proa_ui::components::slider::SliderPositionStyle::marker(marker_position)))}
>
{marker.label}
</span>
}
}}
</span>
<input
type="range"
data_slider_input
data_scope={::proa_ui::components::slider::SLIDER_ANATOMY.scope()}
data_part="hidden-input"
data_slot="slider-hidden-input"
data_orientation={rsjs::server_attr(style.orientation().as_str())}
data_disabled={rsjs::server_attr(if self.state.disabled { "true" } else { "false" })}
data_invalid={rsjs::server_attr(if self.state.invalid { "true" } else { "false" })}
data_read_only={rsjs::server_attr(if self.state.read_only { "true" } else { "false" })}
id={control_id}
name={rsjs::server_attr(if self.state.read_only { None } else { self.name })}
min={min.get()}
max={max.get()}
step={values.step}
bind_value={value}
disabled={rsjs::server_attr(self.state.disabled || self.state.read_only)}
readonly={rsjs::server_attr(self.state.read_only)}
required={rsjs::server_attr(self.state.required)}
aria_labelledby={label_id}
aria_describedby={rsjs::server_attr(self.aria_describedby)}
aria_orientation={rsjs::server_attr(style.orientation().as_str())}
aria_valuemin={min.get()}
aria_valuemax={max.get()}
aria_valuenow={value.get() * 1.0}
aria_invalid={rsjs::server_attr(if self.state.invalid { Some("true") } else { None })}
aria_readonly={rsjs::server_attr(if self.state.read_only { Some("true") } else { None })}
aria_required={rsjs::server_attr(if self.state.required { Some("true") } else { None })}
class={::proa_ui::components::slider::SLIDER_RECIPE.hidden_input}
on_input={update_on_input()}
/>
</span>
<input
type="hidden"
data_slider_readonly_value
name={rsjs::server_attr(self.name)}
value={value.get() * 1.0}
disabled={rsjs::server_attr(!self.state.read_only || self.state.disabled)}
/>
</label>
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn controlled_slider_renders_native_input_and_full_anatomy() {
let markers = [
SliderMarker::labeled(0.0, "Low"),
SliderMarker::labeled(100.0, "High"),
];
let html = proa_core::render_to_string(ControlledSlider {
label: "Volume",
min: 0.0,
max: 100.0,
step: 5.0,
value: 35.0,
orientation: SliderOrientation::Horizontal,
state: FormControlState::enabled(),
markers: &markers,
id: Some("controlled-volume"),
name: Some("volume"),
aria_describedby: None,
class: Some("custom-slider"),
on_change: rsjs::noop_handler(),
});
assert!(html.contains("data-controlled-slider"));
assert!(html.contains(r#"type="range""#));
assert!(html.contains(r#"data-rsjs-input-value"#));
assert!(html.contains(r#"value="35.0""#));
assert!(html.contains(r#"style="--slider-position:35.0;""#));
assert!(html.contains(r#"id="controlled-volume--control""#));
assert!(html.contains("custom-slider"));
for part in ::proa_ui::components::slider::SLIDER_ANATOMY.parts() {
assert!(html.contains(&format!(r#"data-part="{part}""#)));
}
}
#[test]
fn controlled_slider_binds_before_its_numeric_callback_and_updates_position() {
let _ = proa_core::render_to_string(ControlledSlider::new(
"Volume",
25.0,
rsjs::noop_handler(),
));
let manifest = rsjs::manifest::manifest();
let js = manifest
.islands
.iter()
.find(|entry| entry.asset_id.starts_with("controlledslider_"))
.expect("ControlledSlider island should be registered")
.js_source;
let bind_input = js
.find("bind_input(")
.expect("native range value should be two-way bound");
let bind_event = js
.find("bind_delegated_events(")
.expect("controlled callback should bind an input handler");
assert!(bind_input < bind_event);
assert!(js.contains(r#"style?.setProperty("--slider-position""#));
assert!(js.contains(r#"setAttribute?.("data-position""#));
assert!(js.contains("* 1.0"));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::slider::resolve_slider_values;
use super::Slider;
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Slider<'_> {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let values = resolve_slider_values(self.min, self.max, self.step, self.value);
block_boundary(cx)?;
md_sync! { { self.label } ": " }.render_md(cx)?;
match self.value_text {
Some(value_text) => value_text.render_md(cx)?,
None => values.value.render_md(cx)?,
}
block_boundary(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slider_renders_label_and_normalized_value() {
let mut out = Vec::new();
let mut cx = WebContext::with_buffer(&mut out);
Slider {
label: "Volume",
value: 140.0,
..Slider::new("Volume")
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "Volume: 100.0\n\n");
}
#[test]
fn slider_markdown_composes_after_inline_prose() {
let mut out = Vec::new();
let mut cx = WebContext::with_buffer(&mut out);
cx.write_md_prepared_static(b"Current setting").unwrap();
Slider {
label: "Brightness",
value: 25.0,
value_text: Some("Low"),
..Slider::new("Brightness")
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"Current setting\n\nBrightness: Low\n\n"
);
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Native-input-first Slider component.
//!
//! Slider keeps the browser's range input as the accessible and form-owning
//! control, then exposes stable compound parts for styling and render targets.
use crate::Anatomy;
#[cfg(feature = "rsjs")]
mod controlled;
mod md;
mod recipe;
mod slider;
/// Stable DOM parts shared by static and controlled Slider renderers.
pub const SLIDER_ANATOMY: Anatomy = Anatomy::new(
"slider",
&[
"root",
"label",
"value-text",
"control",
"track",
"range",
"thumb",
"hidden-input",
"marker-group",
"marker",
],
);
#[cfg(feature = "rsjs")]
pub use controlled::ControlledSlider;
pub use recipe::{
slider_recipe, SliderOrientation, SliderRecipe, SliderRecipeProps, SliderStyle, SLIDER_RECIPE,
};
#[doc(hidden)]
pub use slider::{resolve_slider_values, ResolvedSliderValues, SliderPositionStyle};
pub use slider::{Slider, SliderMarker};
#[cfg(feature = "metadata-export")]
pub use recipe::export_metadata;
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(SLIDER_ANATOMY.scope(), "slider");
assert_eq!(
SLIDER_ANATOMY.parts(),
&[
"root",
"label",
"value-text",
"control",
"track",
"range",
"thumb",
"hidden-input",
"marker-group",
"marker",
]
);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Slider recipe — typed root orientation and stable compound-part slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Slider layout orientation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum SliderOrientation {
#[default]
Horizontal,
Vertical,
}
impl SliderOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Self::Horizontal => "horizontal",
Self::Vertical => "vertical",
}
}
pub const fn classes(self) -> &'static str {
match self {
Self::Horizontal => "w-full",
Self::Vertical => "w-fit",
}
}
}
/// Optional root axes accepted by [`SliderRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SliderRecipeProps {
pub orientation: Option<SliderOrientation>,
}
/// Static classes for every styled Slider part.
#[derive(Debug, Clone, Copy)]
pub struct SliderRecipe {
pub root: &'static str,
pub label: &'static str,
pub value_text: &'static str,
pub control: &'static str,
pub track: &'static str,
pub range: &'static str,
pub thumb: &'static str,
pub hidden_input: &'static str,
pub marker_group: &'static str,
pub marker: &'static str,
orientation_default: SliderOrientation,
}
impl SliderRecipe {
pub const fn resolve(&'static self, props: SliderRecipeProps) -> SliderStyle {
SliderStyle {
recipe: self,
orientation: match props.orientation {
Some(orientation) => orientation,
None => self.orientation_default,
},
}
}
}
/// Fully resolved, allocation-free Slider root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct SliderStyle {
recipe: &'static SliderRecipe,
orientation: SliderOrientation,
}
impl SliderStyle {
pub const fn orientation(self) -> SliderOrientation {
self.orientation
}
}
impl AttrValue for SliderStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.orientation.classes().as_bytes())
}
}
const SLIDER_ROOT: &str = tw_join!(
"group/slider",
"text-foreground",
"data-[disabled=true]:opacity-50",
"data-[read-only=true]:opacity-75"
);
const SLIDER_LABEL: &str = tw_join!(
"grid",
"grid-cols-[minmax(0,1fr)_auto]",
"items-center",
"gap-x-3",
"gap-y-2",
"text-sm",
"font-medium"
);
const SLIDER_VALUE_TEXT: &str = tw_join!(
"text-sm",
"font-medium",
"tabular-nums",
"text-muted-foreground"
);
const SLIDER_CONTROL: &str = tw_join!(
"relative",
"col-span-2",
"block",
"touch-none",
"select-none",
"group-data-[orientation=horizontal]/slider:h-5",
"group-data-[orientation=horizontal]/slider:w-full",
"group-data-[orientation=vertical]/slider:h-48",
"group-data-[orientation=vertical]/slider:w-5",
"group-data-[orientation=vertical]/slider:justify-self-center"
);
const SLIDER_TRACK: &str = tw_join!(
"pointer-events-none",
"absolute",
"rounded-full",
"bg-primary/20",
"group-data-[orientation=horizontal]/slider:left-0",
"group-data-[orientation=horizontal]/slider:top-1/2",
"group-data-[orientation=horizontal]/slider:h-1.5",
"group-data-[orientation=horizontal]/slider:w-full",
"group-data-[orientation=horizontal]/slider:-translate-y-1/2",
"group-data-[orientation=vertical]/slider:bottom-0",
"group-data-[orientation=vertical]/slider:left-1/2",
"group-data-[orientation=vertical]/slider:h-full",
"group-data-[orientation=vertical]/slider:w-1.5",
"group-data-[orientation=vertical]/slider:-translate-x-1/2"
);
const SLIDER_RANGE: &str = tw_join!(
"absolute",
"rounded-full",
"bg-primary",
"group-data-[orientation=horizontal]/slider:left-0",
"group-data-[orientation=horizontal]/slider:top-0",
"group-data-[orientation=horizontal]/slider:h-full",
"group-data-[orientation=horizontal]/slider:[width:calc(var(--slider-position)*1%)]",
"group-data-[orientation=vertical]/slider:bottom-0",
"group-data-[orientation=vertical]/slider:left-0",
"group-data-[orientation=vertical]/slider:w-full",
"group-data-[orientation=vertical]/slider:[height:calc(var(--slider-position)*1%)]"
);
// The browser-owned range thumb is the moving visual thumb. This DOM part is
// retained as a stable anatomy/styling hook without leaving a stale duplicate
// behind when the no-JS native input moves.
const SLIDER_THUMB: &str = tw_join!(
"pointer-events-none",
"absolute",
"size-4",
"rounded-full",
"border-2",
"border-primary",
"bg-background",
"opacity-0",
"shadow-sm",
"group-data-[orientation=horizontal]/slider:left-[calc(var(--slider-position)*1%)]",
"group-data-[orientation=horizontal]/slider:top-1/2",
"group-data-[orientation=horizontal]/slider:-translate-x-1/2",
"group-data-[orientation=horizontal]/slider:-translate-y-1/2",
"group-data-[orientation=vertical]/slider:bottom-[calc(var(--slider-position)*1%)]",
"group-data-[orientation=vertical]/slider:left-1/2",
"group-data-[orientation=vertical]/slider:-translate-x-1/2",
"group-data-[orientation=vertical]/slider:translate-y-1/2"
);
const SLIDER_HIDDEN_INPUT: &str = tw_join!(
"absolute",
"inset-0",
"z-10",
"m-0",
"h-full",
"w-full",
"cursor-pointer",
"appearance-none",
"bg-transparent",
"outline-hidden",
"focus-visible:ring-2",
"focus-visible:ring-ring",
"focus-visible:ring-offset-2",
"disabled:cursor-not-allowed",
"[&::-webkit-slider-runnable-track]:h-1.5",
"[&::-webkit-slider-runnable-track]:rounded-full",
"[&::-webkit-slider-runnable-track]:bg-transparent",
"[&::-webkit-slider-thumb]:mt-[-5px]",
"[&::-webkit-slider-thumb]:size-4",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:border-2",
"[&::-webkit-slider-thumb]:border-primary",
"[&::-webkit-slider-thumb]:bg-background",
"[&::-webkit-slider-thumb]:shadow-sm",
"[&::-moz-range-track]:h-1.5",
"[&::-moz-range-track]:rounded-full",
"[&::-moz-range-track]:bg-transparent",
"[&::-moz-range-thumb]:size-4",
"[&::-moz-range-thumb]:rounded-full",
"[&::-moz-range-thumb]:border-2",
"[&::-moz-range-thumb]:border-primary",
"[&::-moz-range-thumb]:bg-background",
"[&::-moz-range-thumb]:shadow-sm",
"group-data-[orientation=vertical]/slider:[direction:rtl]",
"group-data-[orientation=vertical]/slider:[writing-mode:vertical-lr]"
);
const SLIDER_MARKER_GROUP: &str = tw_join!(
"pointer-events-none",
"absolute",
"inset-0",
"text-[10px]",
"font-normal",
"text-muted-foreground"
);
const SLIDER_MARKER: &str = tw_join!(
"absolute",
"whitespace-nowrap",
"group-data-[orientation=horizontal]/slider:left-[calc(var(--slider-marker-position)*1%)]",
"group-data-[orientation=horizontal]/slider:top-full",
"group-data-[orientation=horizontal]/slider:mt-1",
"group-data-[orientation=horizontal]/slider:-translate-x-1/2",
"group-data-[orientation=vertical]/slider:bottom-[calc(var(--slider-marker-position)*1%)]",
"group-data-[orientation=vertical]/slider:right-full",
"group-data-[orientation=vertical]/slider:mr-2",
"group-data-[orientation=vertical]/slider:translate-y-1/2"
);
/// Global, zero-initialization Slider recipe.
pub const SLIDER_RECIPE: SliderRecipe = SliderRecipe {
root: SLIDER_ROOT,
label: SLIDER_LABEL,
value_text: SLIDER_VALUE_TEXT,
control: SLIDER_CONTROL,
track: SLIDER_TRACK,
range: SLIDER_RANGE,
thumb: SLIDER_THUMB,
hidden_input: SLIDER_HIDDEN_INPUT,
marker_group: SLIDER_MARKER_GROUP,
marker: SLIDER_MARKER,
orientation_default: SliderOrientation::Horizontal,
};
pub const fn slider_recipe() -> &'static SliderRecipe {
&SLIDER_RECIPE
}
impl crate::variant_spec::ComponentSpec for SliderRecipe {
const NAME: &'static str = "Slider";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<SliderOrientation>()]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::SLIDER_ANATOMY)
}
}
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
crate::variant_spec::component_metadata(&SLIDER_RECIPE)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::variant_spec::ComponentSpec;
#[test]
fn recipe_exposes_every_named_slot() {
let recipe = slider_recipe();
assert!(recipe.root.contains("group/slider"));
assert!(recipe.label.contains("grid-cols"));
assert!(recipe.control.contains("relative"));
assert!(recipe.track.contains("bg-primary/20"));
assert!(recipe.range.contains("--slider-position"));
assert!(recipe.thumb.contains("--slider-position"));
assert!(recipe.hidden_input.contains("webkit-slider-thumb"));
assert!(recipe.marker_group.contains("pointer-events-none"));
assert!(recipe.marker.contains("--slider-marker-position"));
}
#[test]
fn recipe_resolves_typed_orientation() {
let default = SLIDER_RECIPE.resolve(SliderRecipeProps::default());
assert_eq!(default.orientation(), SliderOrientation::Horizontal);
let vertical = SLIDER_RECIPE.resolve(SliderRecipeProps {
orientation: Some(SliderOrientation::Vertical),
});
assert_eq!(vertical.orientation(), SliderOrientation::Vertical);
assert_eq!(vertical.orientation().as_str(), "vertical");
assert_eq!(vertical.orientation().classes(), "w-fit");
}
#[test]
fn component_spec_exports_orientation_and_anatomy() {
assert_eq!(SliderRecipe::NAME, "Slider");
let variants = SLIDER_RECIPE.variant_metadata();
assert_eq!(variants.len(), 1);
assert_eq!(variants[0].name, "orientation");
assert_eq!(
SLIDER_RECIPE.anatomy(),
Some(crate::components::slider::SLIDER_ANATOMY)
);
}
}
slider.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Server-rendered native Slider.
use super::{SliderOrientation, SliderRecipeProps, SLIDER_ANATOMY, SLIDER_RECIPE};
use crate::{FormControlIds, FormControlState, PartId};
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteBuf, WriteError};
use proa_macros::html_sync;
/// A labeled position marker rendered along a Slider track.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SliderMarker<'a> {
pub value: f64,
pub label: Option<&'a str>,
}
impl SliderMarker<'_> {
pub const fn new(value: f64) -> Self {
Self { value, label: None }
}
pub const fn labeled(value: f64, label: &'static str) -> SliderMarker<'static> {
SliderMarker {
value,
label: Some(label),
}
}
}
/// A native-input-first single-value Slider.
///
/// The range input remains the browser-owned accessible and form control.
/// The surrounding parts expose a stable anatomy for styling and codegen.
pub struct Slider<'a> {
pub label: &'a str,
pub value_text: Option<&'a str>,
pub min: f64,
pub max: f64,
pub step: f64,
pub value: f64,
pub orientation: SliderOrientation,
pub state: FormControlState,
pub markers: &'a [SliderMarker<'a>],
pub id: Option<&'a str>,
pub name: Option<&'a str>,
pub aria_describedby: Option<&'a str>,
pub class: Option<&'a str>,
}
impl<'a> Slider<'a> {
pub const fn new(label: &'a str) -> Self {
Self {
label,
value_text: None,
min: 0.0,
max: 100.0,
step: 1.0,
value: 0.0,
orientation: SliderOrientation::Horizontal,
state: FormControlState::enabled(),
markers: &[],
id: None,
name: None,
aria_describedby: None,
class: None,
}
}
pub const fn with_id(mut self, id: &'a str) -> Self {
self.id = Some(id);
self
}
}
impl Default for Slider<'static> {
fn default() -> Self {
Self::new("Value")
}
}
impl<B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for Slider<'_> {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let values = resolve_slider_values(self.min, self.max, self.step, self.value);
let style = SLIDER_RECIPE.resolve(SliderRecipeProps {
orientation: Some(self.orientation),
});
let ids = FormControlIds::new(self.id);
let control_id = ids.control();
let label_id = ids.label();
let value_text_id = PartId::new(self.id, "value-text");
let range_name = if self.state.read_only {
None
} else {
self.name
};
let effective_disabled = self.state.disabled || self.state.read_only;
let aria_invalid = if self.state.invalid {
Some("true")
} else {
None
};
let aria_read_only = if self.state.read_only {
Some("true")
} else {
None
};
let aria_required = if self.state.required {
Some("true")
} else {
None
};
let data_disabled = if self.state.disabled { "true" } else { "false" };
let data_invalid = if self.state.invalid { "true" } else { "false" };
let data_read_only = if self.state.read_only {
"true"
} else {
"false"
};
let data_required = if self.state.required { "true" } else { "false" };
html_sync! {
<div
data-slider
data-scope={SLIDER_ANATOMY.scope()}
data-part="root"
data-slot="slider"
data-orientation={style.orientation().as_str()}
data-disabled={data_disabled}
data-invalid={data_invalid}
data-read-only={data_read_only}
data-required={data_required}
id={self.id}
class={proa_macros::text!("{} {}", style, self.class)}
>
<label
data-scope={SLIDER_ANATOMY.scope()}
data-part="label"
data-slot="slider-label"
id={label_id}
for={control_id}
class={SLIDER_RECIPE.label}
>
<span>{self.label}</span>
<output
data-scope={SLIDER_ANATOMY.scope()}
data-part="value-text"
data-slot="slider-value-text"
id={value_text_id}
for={control_id}
class={SLIDER_RECIPE.value_text}
>
{SliderValueDisplay { value: values.value, text: self.value_text }}
</output>
<span
data-scope={SLIDER_ANATOMY.scope()}
data-part="control"
data-slot="slider-control"
data-position={values.position}
class={SLIDER_RECIPE.control}
style={proa_core::style_ok(SliderPositionStyle::value(values.position))}
>
<span
data-scope={SLIDER_ANATOMY.scope()}
data-part="track"
data-slot="slider-track"
aria-hidden="true"
class={SLIDER_RECIPE.track}
>
<span
data-scope={SLIDER_ANATOMY.scope()}
data-part="range"
data-slot="slider-range"
class={SLIDER_RECIPE.range}
></span>
</span>
<span
data-scope={SLIDER_ANATOMY.scope()}
data-part="thumb"
data-slot="slider-thumb"
aria-hidden="true"
class={SLIDER_RECIPE.thumb}
></span>
<span
data-scope={SLIDER_ANATOMY.scope()}
data-part="marker-group"
data-slot="slider-marker-group"
aria-hidden="true"
class={SLIDER_RECIPE.marker_group}
>
{for marker in self.markers {
let marker_position = values.position_for(marker.value);
html_sync! {
<span
data-scope={SLIDER_ANATOMY.scope()}
data-part="marker"
data-slot="slider-marker"
data-value={marker.value}
data-position={marker_position}
class={SLIDER_RECIPE.marker}
style={proa_core::style_ok(SliderPositionStyle::marker(marker_position))}
>
{SliderValueDisplay { value: marker.value, text: marker.label }}
</span>
}
}}
</span>
<input
type="range"
data-slider-input
data-scope={SLIDER_ANATOMY.scope()}
data-part="hidden-input"
data-slot="slider-hidden-input"
data-orientation={style.orientation().as_str()}
data-disabled={data_disabled}
data-invalid={data_invalid}
data-read-only={data_read_only}
id={control_id}
name={range_name}
min={values.min}
max={values.max}
step={values.step}
value={values.value}
disabled={effective_disabled}
readonly={self.state.read_only}
required={self.state.required}
aria-labelledby={label_id}
aria-describedby={self.aria_describedby}
aria-orientation={style.orientation().as_str()}
aria-valuemin={values.min}
aria-valuemax={values.max}
aria-valuenow={values.value}
aria-valuetext={self.value_text}
aria-invalid={aria_invalid}
aria-readonly={aria_read_only}
aria-required={aria_required}
class={SLIDER_RECIPE.hidden_input}
/>
</span>
<input
type="hidden"
data-slider-readonly-value
name={self.name}
value={values.value}
disabled={!self.state.read_only || self.state.disabled}
/>
</label>
</div>
}
.render(cx)
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
pub struct ResolvedSliderValues {
pub min: f64,
pub max: f64,
pub step: f64,
pub value: f64,
pub position: f64,
}
impl ResolvedSliderValues {
pub fn position_for(self, value: f64) -> f64 {
let value = if value.is_finite() {
value.clamp(self.min, self.max)
} else {
self.min
};
((value - self.min) / (self.max - self.min) * 100.0).clamp(0.0, 100.0)
}
}
#[doc(hidden)]
pub fn resolve_slider_values(
requested_min: f64,
requested_max: f64,
requested_step: f64,
requested_value: f64,
) -> ResolvedSliderValues {
let (min, max) = if requested_min.is_finite()
&& requested_max.is_finite()
&& requested_max > requested_min
{
(requested_min, requested_max)
} else {
(0.0, 100.0)
};
let step = if requested_step.is_finite() && requested_step > 0.0 {
requested_step
} else {
1.0
};
let value = if requested_value.is_finite() {
requested_value.clamp(min, max)
} else {
min
};
let position = ((value - min) / (max - min) * 100.0).clamp(0.0, 100.0);
ResolvedSliderValues {
min,
max,
step,
value,
position,
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
pub struct SliderPositionStyle {
position: f64,
marker: bool,
}
impl SliderPositionStyle {
pub const fn value(position: f64) -> Self {
Self {
position,
marker: false,
}
}
pub const fn marker(position: f64) -> Self {
Self {
position,
marker: true,
}
}
}
impl AttrValue for SliderPositionStyle {
fn should_render(&self) -> bool {
true
}
fn render_attr_value<Buf: WriteBuf, Loader: DataLoader>(
&self,
cx: &mut WebContext<Loader, Buf>,
) -> Result<(), WriteError> {
if self.marker {
cx.out.extend_static(b"--slider-marker-position:")?;
} else {
cx.out.extend_static(b"--slider-position:")?;
}
self.position.render_attr_value(cx)?;
cx.out.extend_static(b";")
}
}
#[derive(Debug, Clone, Copy)]
struct SliderValueDisplay<'a> {
value: f64,
text: Option<&'a str>,
}
impl<B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for SliderValueDisplay<'_> {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
match self.text {
Some(text) => text.render(cx),
None => self.value.render(cx),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::{ctx::Ctx, WebRenderSync};
fn render(slider: Slider<'_>) -> String {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
slider.render(&mut cx).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn native_range_owns_accessibility_form_and_deterministic_ids() {
let html = render(Slider {
label: "Volume",
value: 42.0,
id: Some("volume"),
name: Some("volume"),
aria_describedby: Some("volume-help"),
..Slider::new("Volume")
});
assert!(html.contains(r#"id="volume""#));
assert!(html.contains(r#"id="volume--label" for="volume--control""#));
assert!(html.contains(r#"id="volume--control" name="volume""#));
assert!(html.contains(r#"id="volume--value-text" for="volume--control""#));
assert!(html.contains(r#"type="range""#));
assert!(html.contains(r#"min="0.0" max="100.0" step="1.0" value="42.0""#));
assert!(html.contains(r#"aria-labelledby="volume--label""#));
assert!(html.contains(r#"aria-describedby="volume-help""#));
assert!(html.contains(r#"aria-orientation="horizontal""#));
assert!(!html.contains("oninput"));
}
#[test]
fn server_position_and_value_are_clamped_to_valid_bounds() {
let html = render(Slider {
min: 10.0,
max: 30.0,
value: 90.0,
..Slider::new("Threshold")
});
assert!(html.contains(r#"min="10.0" max="30.0" step="1.0" value="30.0""#));
assert!(html.contains(r#"data-position="100.0""#));
assert!(html.contains(r#"style="--slider-position:100.0;""#));
assert!(html.contains(r#"aria-valuenow="30.0""#));
}
#[test]
fn invalid_numeric_configuration_falls_back_without_nan_or_infinity() {
let html = render(Slider {
min: 20.0,
max: 10.0,
step: f64::NAN,
value: f64::INFINITY,
..Slider::new("Fallback")
});
assert!(html.contains(r#"min="0.0" max="100.0" step="1.0" value="0.0""#));
assert!(html.contains(r#"style="--slider-position:0.0;""#));
assert!(!html.contains("NaN"));
assert!(!html.contains("inf"));
}
#[test]
fn vertical_orientation_projects_to_root_and_native_control() {
let html = render(Slider {
orientation: SliderOrientation::Vertical,
value: 25.0,
..Slider::new("Brightness")
});
assert_eq!(html.matches(r#"data-orientation="vertical""#).count(), 2);
assert!(html.contains("[writing-mode:vertical-lr]"));
assert!(html.contains(r#"data-position="25.0""#));
}
#[test]
fn form_control_state_projects_disabled_invalid_read_only_and_required() {
let html = render(Slider {
value: 55.0,
id: Some("locked"),
name: Some("locked"),
state: FormControlState {
disabled: false,
invalid: true,
read_only: true,
required: true,
},
..Slider::new("Locked")
});
assert!(html.contains(r#"data-invalid="true""#));
assert!(html.contains(r#"data-read-only="true""#));
assert!(html.contains(r#"data-required="true""#));
assert!(html.contains(r#"aria-invalid="true""#));
assert!(html.contains(r#"aria-readonly="true""#));
assert!(html.contains(r#"aria-required="true""#));
assert!(html.contains(r#"type="range""#));
assert!(html.contains(" disabled"));
assert!(
html.contains(r#"type="hidden" data-slider-readonly-value name="locked" value="55.0""#)
);
}
#[test]
fn marker_positions_and_complete_anatomy_are_server_correct() {
let markers = [
SliderMarker::labeled(0.0, "Low"),
SliderMarker::new(50.0),
SliderMarker::labeled(100.0, "High"),
];
let html = render(Slider {
value: 50.0,
markers: &markers,
value_text: Some("Medium"),
..Slider::new("Level")
});
assert_eq!(html.matches(r#"data-scope="slider""#).count(), 12);
for part in SLIDER_ANATOMY.parts() {
assert!(html.contains(&format!(r#"data-part="{part}""#)));
}
assert_eq!(html.matches(r#"data-part="marker""#).count(), 3);
assert!(html.contains(r#"style="--slider-marker-position:0.0;""#));
assert!(html.contains(r#"style="--slider-marker-position:50.0;""#));
assert!(html.contains(r#"style="--slider-marker-position:100.0;""#));
assert!(html.contains(">Medium</output>"));
assert!(html.contains(">Low</span>"));
assert!(html.contains(">50.0</span>"));
assert!(html.contains(">High</span>"));
}
}