Toggle
A two-state button that can be either on or off.
Default
Click to toggle between on and off states.
Pressed State
Toggle initially in pressed (on) state.
Outline Variant
Toggle with outline styling.
Sizes
Toggle comes in three sizes: sm, default, and lg.
With Text
Toggle with text content.
Disabled
Disabled toggles cannot be interacted with.
Accessibility
- Uses aria-pressed to indicate the toggle state
- Supports Enter and Space key to toggle
- Disabled state prevents interaction
- Use aria-label when toggle contains only an icon
FreeToggle
Pressed/unpressed control state.
Install with the Proa CLI
$ proa ui add toggleInstalls 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) AND MIT
//! Toggle recipe - styling system for toggle component
//!
//! Provides Tailwind classes for toggle styling following shadcn patterns.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Toggle variant
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "variant")]
pub enum ToggleVariant {
/// Default transparent toggle
#[default]
Default,
/// Outlined toggle with border
Outline,
}
Toggle 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 toggle component.
use super::Toggle;
use crate::components::md_support::line_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Toggle renders as a task-list item — `- [x] Label` when pressed,
/// `- [ ] Label` when not. The label comes from the children (rendered
/// inline) or the aria-label.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Toggle<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let mark = if self.pressed { "x" } else { " " };
// Fresh line (not a full blank-line boundary): a lone toggle after
// prose needs its own line, but consecutive toggles in a group must
// stay adjacent with no blank line between them.
line_boundary(cx)?;
cx.write_md_list_indent()?;
md_sync! { "- [" { mark } "] " }.render_md(cx)?;
let prev = cx.md_set_inline(true);
if let Some(children) = self.children {
children.render_md(cx)?;
} else if let Some(label) = self.aria_label {
cx.write_md_text(label)?;
}
cx.md_set_inline(prev);
md_sync! { "" "\n" }.render_md(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_unpressed_renders_children() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Toggle {
children: Some("Bold"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- [ ] Bold\n");
}
#[test]
fn toggle_pressed_marks_item() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Toggle {
pressed: true,
children: Some("Italic"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- [x] Italic\n");
}
/// A toggle composed after mid-line prose must start its task item on a
/// fresh line, while consecutive toggles stay blank-line free.
#[test]
fn toggle_after_prose_starts_on_fresh_line() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Toggle {
children: Some("Bold"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
Toggle {
pressed: true,
children: Some("Italic"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n- [ ] Bold"),
"output: {output}"
);
assert!(
output.contains("- [ ] Bold\n- [x] Italic\n"),
"output: {output}"
);
}
#[test]
fn toggle_falls_back_to_aria_label() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Toggle::<&str> {
aria_label: Some("Toggle bold"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "- [ ] Toggle bold\n");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Toggle component module
//!
//! A two-state button that can be either on or off.
//!
//! Based on Radix UI Toggle with shadcn/ui styling.
mod md;
#[allow(clippy::module_inception)]
mod recipe;
mod toggle;
pub use recipe::{
toggle_recipe, ToggleRecipe, ToggleRecipeProps, ToggleSize, ToggleStyle, ToggleVariant,
TOGGLE_RECIPE,
};
pub use toggle::Toggle;
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Toggle recipe - styling system for toggle component
//!
//! Provides Tailwind classes for toggle styling following shadcn patterns.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Toggle variant
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "variant")]
pub enum ToggleVariant {
/// Default transparent toggle
#[default]
Default,
/// Outlined toggle with border
Outline,
}
impl ToggleVariant {
pub const fn as_str(self) -> &'static str {
match self {
ToggleVariant::Default => "default",
ToggleVariant::Outline => "outline",
}
}
pub const fn classes(self) -> &'static str {
toggle_variant_classes(self)
}
}
/// Toggle size
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum ToggleSize {
/// Small toggle
Sm,
/// Default medium toggle
#[default]
Default,
/// Large toggle
Lg,
}
impl ToggleSize {
pub const fn as_str(self) -> &'static str {
match self {
ToggleSize::Sm => "sm",
ToggleSize::Default => "default",
ToggleSize::Lg => "lg",
}
}
pub const fn classes(self) -> &'static str {
toggle_size_classes(self)
}
}
/// Base toggle styles
pub const TOGGLE_BASE: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"rounded-md",
"text-sm",
"font-medium",
"transition-colors",
"hover:bg-muted",
"hover:text-muted-foreground",
"disabled:pointer-events-none",
"disabled:opacity-50",
"data-[state=on]:bg-accent",
"data-[state=on]:text-accent-foreground",
"focus-visible:outline-hidden",
"focus-visible:ring-2",
"focus-visible:ring-ring",
"focus-visible:ring-offset-2",
"ring-offset-background"
);
/// Default variant classes
pub const TOGGLE_VARIANT_DEFAULT: &str = "bg-transparent";
/// Outline variant classes
pub const TOGGLE_VARIANT_OUTLINE: &str = tw_join!(
"border",
"border-input",
"bg-transparent",
"shadow-sm",
"hover:bg-accent",
"hover:text-accent-foreground"
);
/// Small size classes
pub const TOGGLE_SIZE_SM: &str = tw_join!("h-8", "px-1.5", "min-w-8");
/// Default size classes
pub const TOGGLE_SIZE_DEFAULT: &str = tw_join!("h-9", "px-2", "min-w-9");
/// Large size classes
pub const TOGGLE_SIZE_LG: &str = tw_join!("h-10", "px-2.5", "min-w-10");
/// Get toggle base classes
pub const fn toggle_base_classes() -> &'static str {
TOGGLE_BASE
}
/// Get variant classes
pub const fn toggle_variant_classes(variant: ToggleVariant) -> &'static str {
match variant {
ToggleVariant::Default => TOGGLE_VARIANT_DEFAULT,
ToggleVariant::Outline => TOGGLE_VARIANT_OUTLINE,
}
}
/// Get size classes
pub const fn toggle_size_classes(size: ToggleSize) -> &'static str {
match size {
ToggleSize::Sm => TOGGLE_SIZE_SM,
ToggleSize::Default => TOGGLE_SIZE_DEFAULT,
ToggleSize::Lg => TOGGLE_SIZE_LG,
}
}
/// Optional inputs accepted by [`ToggleRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ToggleRecipeProps {
pub variant: Option<ToggleVariant>,
pub size: Option<ToggleSize>,
}
/// Compile-time Toggle recipe.
#[derive(Debug, Clone, Copy)]
pub struct ToggleRecipe {
root: &'static str,
variant_default: ToggleVariant,
size_default: ToggleSize,
}
impl ToggleRecipe {
pub const fn resolve(&'static self, props: ToggleRecipeProps) -> ToggleStyle {
let variant = match props.variant {
Some(variant) => variant,
None => self.variant_default,
};
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
ToggleStyle {
recipe: self,
variant,
size,
}
}
}
/// Fully resolved, streamable Toggle style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct ToggleStyle {
recipe: &'static ToggleRecipe,
variant: ToggleVariant,
size: ToggleSize,
}
impl ToggleStyle {
pub const fn variant(self) -> ToggleVariant {
self.variant
}
pub const fn size(self) -> ToggleSize {
self.size
}
}
impl AttrValue for ToggleStyle {
#[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.variant.classes().as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())
}
}
pub const TOGGLE_RECIPE: ToggleRecipe = ToggleRecipe {
root: toggle_base_classes(),
variant_default: ToggleVariant::Default,
size_default: ToggleSize::Default,
};
pub const fn toggle_recipe() -> &'static ToggleRecipe {
&TOGGLE_RECIPE
}
impl crate::variant_spec::ComponentSpec for ToggleRecipe {
const NAME: &'static str = "Toggle";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![
crate::variant_spec::variant_metadata::<ToggleVariant>(),
crate::variant_spec::variant_metadata::<ToggleSize>(),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_base_has_flex() {
let classes = toggle_base_classes();
assert!(classes.contains("inline-flex"));
assert!(classes.contains("data-[state=on]:bg-accent"));
}
#[test]
fn toggle_variant_default_is_transparent() {
let classes = toggle_variant_classes(ToggleVariant::Default);
assert!(classes.contains("bg-transparent"));
}
#[test]
fn toggle_variant_outline_has_border() {
let classes = toggle_variant_classes(ToggleVariant::Outline);
assert!(classes.contains("border"));
assert!(classes.contains("border-input"));
}
#[test]
fn toggle_sizes_have_correct_height() {
assert!(toggle_size_classes(ToggleSize::Sm).contains("h-8"));
assert!(toggle_size_classes(ToggleSize::Default).contains("h-9"));
assert!(toggle_size_classes(ToggleSize::Lg).contains("h-10"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = toggle_recipe().resolve(ToggleRecipeProps::default());
assert_eq!(default.variant(), ToggleVariant::Default);
assert_eq!(default.size(), ToggleSize::Default);
let custom = toggle_recipe().resolve(ToggleRecipeProps {
variant: Some(ToggleVariant::Outline),
size: Some(ToggleSize::Lg),
});
assert_eq!(custom.variant(), ToggleVariant::Outline);
assert_eq!(custom.size(), ToggleSize::Lg);
}
}
toggle.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Toggle component implementation
//!
//! A two-state button that can be either on or off.
//! Based on Radix UI Toggle with shadcn/ui styling.
use super::recipe::{ToggleRecipeProps, ToggleSize, ToggleVariant, TOGGLE_RECIPE};
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Toggle component - a two-state button
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Toggle, ToggleVariant, ToggleSize};
/// use proa_macros::html_sync;
///
/// html_sync! {
/// {Toggle {
/// pressed: false,
/// children: Some("B"),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct Toggle<C = ()> {
/// Whether the toggle is pressed (on)
pub pressed: bool,
/// Visual variant
pub variant: ToggleVariant,
/// Toggle size
pub size: ToggleSize,
/// Whether the toggle is disabled
pub disabled: bool,
/// ARIA label for accessibility
pub aria_label: Option<&'static str>,
/// Additional classes
pub class: Option<&'static str>,
/// Toggle content (usually an icon or text)
pub children: Option<C>,
}
impl Toggle<()> {
pub const fn new() -> Self {
Self {
pressed: false,
variant: ToggleVariant::Default,
size: ToggleSize::Default,
disabled: false,
aria_label: None,
class: None,
children: None,
}
}
}
impl<C> Default for Toggle<C> {
fn default() -> Self {
Self {
pressed: false,
variant: ToggleVariant::Default,
size: ToggleSize::Default,
disabled: false,
aria_label: None,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for Toggle<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = TOGGLE_RECIPE.resolve(ToggleRecipeProps {
variant: Some(self.variant),
size: Some(self.size),
});
let state = if self.pressed { "on" } else { "off" };
let aria_pressed = if self.pressed { "true" } else { "false" };
let disabled = if self.disabled { "true" } else { "false" };
html_sync! {
<button
type="button"
data-toggle
data-slot="toggle"
data-variant={style.variant().as_str()}
data-size={style.size().as_str()}
data-state={state}
data-disabled={disabled}
aria-pressed={aria_pressed}
aria-label={self.aria_label}
disabled={self.disabled}
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn toggle_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Toggle {
pressed: false,
children: Some(text("B")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-toggle"));
assert!(html.contains("data-slot=\"toggle\""));
assert!(html.contains("data-size=\"default\""));
assert!(html.contains("data-state=\"off\""));
assert!(html.contains("aria-pressed=\"false\""));
}
#[test]
fn toggle_pressed_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Toggle {
pressed: true,
children: Some(text("B")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-state=\"on\""));
assert!(html.contains("aria-pressed=\"true\""));
}
#[test]
fn toggle_disabled_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Toggle {
disabled: true,
children: Some(text("B")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-disabled=\"true\""));
assert!(html.contains(" disabled"));
}
#[test]
fn toggle_with_aria_label() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Toggle {
aria_label: Some("Toggle bold"),
children: Some(text("B")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-label=\"Toggle bold\""));
}
#[test]
fn toggle_outline_variant() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Toggle {
variant: ToggleVariant::Outline,
children: Some(text("B")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("border"));
}
}