Switch
A toggle control that switches between checked and unchecked states.
Default
A basic switch in its unchecked state.
Checked
A switch rendered in its checked (on) state.
Sizes
Switch comes in two sizes: default and sm.
Small
Default
Disabled
Disabled switches cannot be interacted with.
Disabled (off)
Disabled (on)
With Label
Switches paired with labels for common settings patterns.
Airplane Mode
Disable all wireless connections
Wi-Fi
Connect to wireless networks
Bluetooth
Connect to Bluetooth devices
Accessibility
- Uses role="switch" for proper semantics
- aria-checked reflects the current state
- Supports Enter and Space key to toggle
- Disabled state prevents interaction
- Use aria-label when the switch has no visible label
FreeSwitch
Boolean toggle control.
Install with the Proa CLI
$ proa ui add switchInstalls 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
//! Switch recipe — typed root sizing and a named thumb slot.
//!
//! The root resolves size once and publishes the thumb dimensions as CSS
//! variables. The thumb consumes those variables, keeping the root as the
//! single source of truth for the compound component's appearance.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Size variants for the switch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum SwitchSize {
/// Small switch.
Sm,
/// Default switch size.
#[default]
Default,
Switch source
Browse and copy the reviewed source included with this Free component.
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Markdown rendering for the switch component.
use super::Switch;
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Switch renders as `**Label:** on/off` (label from aria-label or name),
/// or just `on`/`off` when unlabeled.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Switch {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let state = if self.checked { "on" } else { "off" };
if let Some(label) = self.aria_label.or(self.name) {
// The labeled form is a block-level `**Label:** state` line.
block_boundary(cx)?;
md_sync! { "**" { label } ":** " { state } }.render_md(cx)?;
block_boundary(cx)?;
} else {
// Unlabeled, the bare state is inline text.
md_sync! { { state } }.render_md(cx)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn switch_off_with_label() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Switch {
aria_label: Some("Airplane Mode"),
..Switch::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"**Airplane Mode:** off\n\n"
);
}
#[test]
fn switch_on_falls_back_to_name() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Switch {
name: Some("notifications"),
..Switch::checked()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "**notifications:** on\n\n");
}
/// A labeled switch composed after mid-line prose must start its
/// `**Label:** state` line at a blank-line boundary.
#[test]
fn switch_after_prose_starts_new_block() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Switch {
aria_label: Some("Airplane Mode"),
..Switch::new()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n**Airplane Mode:** off"),
"output: {output}"
);
}
#[test]
fn switch_unlabeled_renders_state_only() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Switch::checked().render_md(&mut cx).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "on");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Switch component - a sliding toggle control
//!
//! The Switch component provides a toggle that switches between checked and
//! unchecked states, styled as a sliding control similar to iOS-style switches.
use crate::Anatomy;
mod md;
mod recipe;
mod switch;
/// Stable DOM parts exposed by Switch.
pub const SWITCH_ANATOMY: Anatomy = Anatomy::new("switch", &["root", "thumb"]);
// Re-export switch types
pub use switch::Switch;
// Re-export recipe types
pub use recipe::{
switch_recipe, switch_thumb_classes, switch_track_classes, SwitchRecipe, SwitchRecipeProps,
SwitchSize, SwitchStyle, SWITCH_RECIPE,
};
// Re-export metadata export function (only when feature is enabled)
#[cfg(feature = "metadata-export")]
pub use recipe::export_metadata;
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_switch_root_and_thumb() {
assert_eq!(SWITCH_ANATOMY.scope(), "switch");
assert_eq!(SWITCH_ANATOMY.parts(), &["root", "thumb"]);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Switch recipe — typed root sizing and a named thumb slot.
//!
//! The root resolves size once and publishes the thumb dimensions as CSS
//! variables. The thumb consumes those variables, keeping the root as the
//! single source of truth for the compound component's appearance.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Size variants for the switch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum SwitchSize {
/// Small switch.
Sm,
/// Default switch size.
#[default]
Default,
}
impl SwitchSize {
/// Root sizing plus variables inherited by the thumb slot.
pub const fn classes(self) -> &'static str {
match self {
Self::Sm => tw_join!(
"h-4",
"w-7",
"[--switch-thumb-size:0.75rem]",
"[--switch-thumb-translate:0.75rem]"
),
Self::Default => tw_join!(
"h-5",
"w-9",
"[--switch-thumb-size:1rem]",
"[--switch-thumb-translate:1rem]"
),
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Default => "default",
}
}
}
mod base {
use super::*;
pub const SWITCH_ROOT: &str = tw_join!(
"peer",
"inline-flex",
"shrink-0",
"cursor-pointer",
"items-center",
"rounded-full",
"border-2",
"border-transparent",
"shadow-xs",
"transition-colors",
"focus-visible:outline-hidden",
"focus-visible:ring-2",
"focus-visible:ring-ring",
"focus-visible:ring-offset-2",
"focus-visible:ring-offset-background",
"disabled:cursor-not-allowed",
"disabled:opacity-50",
"data-[state=checked]:bg-primary",
"data-[state=unchecked]:bg-input"
);
pub const SWITCH_THUMB: &str = tw_join!(
"pointer-events-none",
"block",
"size-[var(--switch-thumb-size)]",
"rounded-full",
"bg-background",
"shadow-lg",
"ring-0",
"transition-transform",
"data-[state=unchecked]:translate-x-0",
"data-[state=checked]:translate-x-[var(--switch-thumb-translate)]"
);
pub const SWITCH_THUMB_SM_COMPAT: &str = tw_join!(
"pointer-events-none",
"block",
"size-3",
"rounded-full",
"bg-background",
"shadow-lg",
"ring-0",
"transition-transform",
"data-[state=unchecked]:translate-x-0",
"data-[state=checked]:translate-x-3"
);
pub const SWITCH_THUMB_DEFAULT_COMPAT: &str = tw_join!(
"pointer-events-none",
"block",
"size-4",
"rounded-full",
"bg-background",
"shadow-lg",
"ring-0",
"transition-transform",
"data-[state=unchecked]:translate-x-0",
"data-[state=checked]:translate-x-4"
);
}
/// Optional inputs accepted by [`SwitchRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SwitchRecipeProps {
pub size: Option<SwitchSize>,
}
/// Static named slots for the Switch compound component.
#[derive(Debug, Clone, Copy)]
pub struct SwitchRecipe {
pub root: &'static str,
pub thumb: &'static str,
size_default: SwitchSize,
}
impl SwitchRecipe {
pub const fn resolve(&'static self, props: SwitchRecipeProps) -> SwitchStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
SwitchStyle { recipe: self, size }
}
}
/// Fully resolved, streamable Switch root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct SwitchStyle {
recipe: &'static SwitchRecipe,
size: SwitchSize,
}
impl SwitchStyle {
pub const fn size(self) -> SwitchSize {
self.size
}
}
impl AttrValue for SwitchStyle {
#[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.size.classes().as_bytes())
}
}
/// Global, zero-initialization Switch recipe.
pub const SWITCH_RECIPE: SwitchRecipe = SwitchRecipe {
root: base::SWITCH_ROOT,
thumb: base::SWITCH_THUMB,
size_default: SwitchSize::Default,
};
pub const fn switch_recipe() -> &'static SwitchRecipe {
&SWITCH_RECIPE
}
/// Compatibility resolver for the former track helper.
pub const fn switch_track_classes(size: SwitchSize) -> SwitchStyle {
SWITCH_RECIPE.resolve(SwitchRecipeProps { size: Some(size) })
}
/// Compatibility accessor for the former independently sized thumb helper.
///
/// New render code should use [`SWITCH_RECIPE`]'s `thumb` slot, which inherits
/// the size resolved by the nearest Switch root.
pub const fn switch_thumb_classes(size: SwitchSize) -> &'static str {
match size {
SwitchSize::Sm => base::SWITCH_THUMB_SM_COMPAT,
SwitchSize::Default => base::SWITCH_THUMB_DEFAULT_COMPAT,
}
}
impl crate::variant_spec::ComponentSpec for SwitchRecipe {
const NAME: &'static str = "Switch";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<SwitchSize>()]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::SWITCH_ANATOMY)
}
}
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
crate::variant_spec::component_metadata(&SWITCH_RECIPE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_root_and_thumb_slots() {
let recipe = switch_recipe();
assert!(recipe.root.contains("inline-flex"));
assert!(recipe.root.contains("data-[state=checked]:bg-primary"));
assert!(recipe.thumb.contains("--switch-thumb-size"));
assert!(recipe.thumb.contains("--switch-thumb-translate"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = SWITCH_RECIPE.resolve(SwitchRecipeProps::default());
assert_eq!(default.size(), SwitchSize::Default);
assert!(default.size().classes().contains("h-5"));
let small = SWITCH_RECIPE.resolve(SwitchRecipeProps {
size: Some(SwitchSize::Sm),
});
assert_eq!(small.size(), SwitchSize::Sm);
assert!(small.size().classes().contains("h-4"));
}
#[test]
fn every_size_sets_both_inherited_thumb_variables() {
for size in [SwitchSize::Sm, SwitchSize::Default] {
assert!(size.classes().contains("--switch-thumb-size"));
assert!(size.classes().contains("--switch-thumb-translate"));
}
}
#[test]
fn compatibility_thumb_helper_remains_size_specific() {
assert!(switch_thumb_classes(SwitchSize::Sm).contains("size-3"));
assert!(switch_thumb_classes(SwitchSize::Default).contains("size-4"));
}
}
switch.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Switch component implementation
//!
//! A toggle control that switches between checked and unchecked states.
//! Styled as a sliding toggle, similar to iOS-style switches.
use super::recipe::{SwitchRecipeProps, SwitchSize, SWITCH_RECIPE};
use super::SWITCH_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Switch component - a sliding toggle control
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Switch, SwitchSize};
/// use proa_macros::html_sync;
///
/// html_sync! {
/// <div class="flex items-center gap-2">
/// {Switch {
/// id: Some("airplane-mode"),
/// ..Switch::new()
/// }}
/// <label for="airplane-mode">"Airplane Mode"</label>
/// </div>
/// }
/// ```
pub struct Switch {
/// Size variant
pub size: SwitchSize,
/// Whether the switch is checked (on)
pub checked: bool,
/// Whether the switch is disabled
pub disabled: bool,
/// The name attribute for form submission
pub name: Option<&'static str>,
/// The id attribute
pub id: Option<&'static str>,
/// Additional CSS classes
pub class: Option<&'static str>,
/// ARIA label for accessibility
pub aria_label: Option<&'static str>,
}
impl Switch {
/// Create a new switch with default settings
pub const fn new() -> Self {
Self {
size: SwitchSize::Default,
checked: false,
disabled: false,
name: None,
id: None,
class: None,
aria_label: None,
}
}
/// Create a checked switch
pub const fn checked() -> Self {
Self {
size: SwitchSize::Default,
checked: true,
disabled: false,
name: None,
id: None,
class: None,
aria_label: None,
}
}
}
impl Default for Switch {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Switch {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = SWITCH_RECIPE.resolve(SwitchRecipeProps {
size: Some(self.size),
});
let checked_state = crate::CheckedState::from_checked(self.checked);
let form_state = crate::FormControlState {
disabled: self.disabled,
..crate::FormControlState::enabled()
};
html_sync! {
<button
type="button"
role="switch"
aria-checked={checked_state.aria_checked()}
aria-disabled={form_state.aria_disabled()}
aria-label={self.aria_label}
data-state={checked_state}
data-disabled={form_state.data_disabled()}
data-size={style.size().as_str()}
data-switch
data-scope={SWITCH_ANATOMY.scope()}
data-part="root"
data-slot="switch"
id={self.id}
name={self.name}
disabled={self.disabled}
class={proa_macros::text!("{} {}", style, self.class)}
>
<span
data-switch-thumb
data-scope={SWITCH_ANATOMY.scope()}
data-part="thumb"
data-slot="switch-thumb"
data-state={checked_state}
class={SWITCH_RECIPE.thumb}
></span>
</button>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
#[test]
fn switch_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<button"));
assert!(html.contains("role=\"switch\""));
assert!(html.contains("aria-checked=\"false\""));
assert!(html.contains("data-state=\"unchecked\""));
assert!(html.contains("data-switch"));
assert!(html.contains("data-slot=\"switch\""));
assert!(html.contains("data-switch-thumb"));
assert!(html.contains("data-slot=\"switch-thumb\""));
assert!(html.contains("data-scope=\"switch\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("data-part=\"thumb\""));
assert!(html.contains("data-size=\"default\""));
assert!(html.contains("aria-disabled=\"false\""));
}
#[test]
fn switch_renders_checked_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch::checked().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-checked=\"true\""));
assert!(html.contains("data-state=\"checked\""));
}
#[test]
fn switch_renders_disabled() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch {
disabled: true,
..Switch::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" disabled"));
assert!(html.contains("data-disabled=\"true\""));
assert!(html.contains("aria-disabled=\"true\""));
}
#[test]
fn switch_renders_with_id_and_name() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch {
id: Some("my-switch"),
name: Some("notifications"),
..Switch::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("id=\"my-switch\""));
assert!(html.contains("name=\"notifications\""));
}
#[test]
fn switch_renders_aria_label() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch {
aria_label: Some("Enable notifications"),
..Switch::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-label=\"Enable notifications\""));
}
#[test]
fn switch_renders_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch {
class: Some("custom-switch"),
..Switch::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-switch"));
}
#[test]
fn switch_has_track_base_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("inline-flex"));
assert!(html.contains("rounded-full"));
assert!(html.contains("cursor-pointer"));
}
#[test]
fn switch_has_thumb_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("pointer-events-none"));
assert!(html.contains("bg-background"));
assert!(html.contains("shadow-lg"));
}
#[test]
fn switch_renders_sm_size() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Switch {
size: SwitchSize::Sm,
..Switch::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("h-4"));
assert!(html.contains("w-7"));
assert!(html.contains("--switch-thumb-size:0.75rem"));
assert!(html.contains("size-[var(--switch-thumb-size)]"));
}
#[test]
fn switch_size_is_reasonable() {
assert!(std::mem::size_of::<Switch>() < 256);
const _SWITCH: Switch = Switch::new();
}
}