Spinner
Loading indicator for async operations.
Sizes
Spinners come in four sizes.
Small
Medium
Large
Extra Large
With Text
Combine spinners with loading text.
Loading...
FreeSpinner
Compact loading indicators.
Install with the Proa CLI
$ proa ui add spinnerInstalls 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 AND (ISC AND MIT)
//! Spinner recipe - type-safe styling system for Spinner component
//!
//! Inspired by Panda CSS and Ark UI patterns, this module provides a fully
//! typed recipe system for generating Tailwind classes with compile-time safety.
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles module - core spinner styling
mod base {
use crate::theme::{animate, shadcn_utils};
use crate::tw_join;
pub const SPINNER_BASE: &str = tw_join!(
"lucide",
"lucide-loader-circle",
animate::SPIN,
shadcn_utils::SHRINK_0
);
Spinner 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
use super::Spinner;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// Spinner is visual-only — renders nothing in markdown.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Spinner {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
#![allow(clippy::module_inception)]
//! Spinner component - animated loading indicator
//!
//! The Spinner component provides a rotating loader icon for indicating loading states.
//! Uses the Lucide loader-circle icon with CSS animation.
mod md;
mod recipe;
mod spinner;
// Re-export spinner types
pub use spinner::Spinner;
// Re-export recipe types
pub use recipe::{
spinner_classes, spinner_recipe, SpinnerRecipe, SpinnerRecipeProps, SpinnerSize, SpinnerStyle,
SPINNER_RECIPE,
};
// Re-export metadata export function (only when feature is enabled)
#[cfg(feature = "metadata-export")]
pub use recipe::export_metadata;
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT AND (ISC AND MIT)
//! Spinner recipe - type-safe styling system for Spinner component
//!
//! Inspired by Panda CSS and Ark UI patterns, this module provides a fully
//! typed recipe system for generating Tailwind classes with compile-time safety.
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles module - core spinner styling
mod base {
use crate::theme::{animate, shadcn_utils};
use crate::tw_join;
pub const SPINNER_BASE: &str = tw_join!(
"lucide",
"lucide-loader-circle",
animate::SPIN,
shadcn_utils::SHRINK_0
);
}
/// Size styles module - spinner size variations
mod sizes {
/// Spinner size variants
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum SpinnerSize {
Sm, // size-3
#[default]
Default, // size-4
Md, // size-5
Lg, // size-6
Xl, // size-8
}
impl SpinnerSize {
/// Get the Tailwind classes for this size
pub const fn classes(self) -> &'static str {
match self {
SpinnerSize::Sm => "size-3",
SpinnerSize::Default => "size-4",
SpinnerSize::Md => "size-5",
SpinnerSize::Lg => "size-6",
SpinnerSize::Xl => "size-8",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Default => "default",
Self::Md => "md",
Self::Lg => "lg",
Self::Xl => "xl",
}
}
}
}
// Re-export all public types for convenience
pub use sizes::SpinnerSize;
/// Fully typed spinner recipe with compile-time safety
///
/// This recipe provides size control for the spinner icon.
///
/// This is a const struct that uses match-based lookups instead of hashmaps,
/// making it zero-cost at runtime with no initialization overhead.
///
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SpinnerRecipeProps {
pub size: Option<SpinnerSize>,
}
#[derive(Debug, Clone, Copy)]
pub struct SpinnerRecipe {
base: &'static str,
size_default: SpinnerSize,
}
/// Global spinner recipe instance - fully const, zero runtime initialization!
pub const SPINNER_RECIPE: SpinnerRecipe = SpinnerRecipe {
base: base::SPINNER_BASE,
size_default: SpinnerSize::Default,
};
impl SpinnerRecipe {
pub const fn resolve(&'static self, props: SpinnerRecipeProps) -> SpinnerStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
SpinnerStyle { recipe: self, size }
}
}
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct SpinnerStyle {
recipe: &'static SpinnerRecipe,
size: SpinnerSize,
}
impl SpinnerStyle {
pub const fn size(self) -> SpinnerSize {
self.size
}
}
impl AttrValue for SpinnerStyle {
#[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.base.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())
}
}
/// Get the global spinner recipe with default Tailwind styling
///
/// This is now just a convenience function that returns a reference to the const recipe.
/// No initialization overhead, no synchronization, just a simple reference!
pub const fn spinner_recipe() -> &'static SpinnerRecipe {
&SPINNER_RECIPE
}
/// Compatibility resolver for callers of the former helper.
pub const fn spinner_classes(size: SpinnerSize) -> SpinnerStyle {
SPINNER_RECIPE.resolve(SpinnerRecipeProps { size: Some(size) })
}
impl crate::variant_spec::ComponentSpec for SpinnerRecipe {
const NAME: &'static str = "Spinner";
fn base_classes(&self) -> &'static str {
self.base
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<SpinnerSize>()]
}
}
/// Lucide loader-circle SVG path
#[allow(dead_code)]
const LOADER_CIRCLE_PATH: &str = r#"<path d="M21 12a9 9 0 1 1-6.219-8.56" />"#;
/// Export metadata for TypeScript code generation
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
use crate::variant_spec::{component_metadata_with_props, ComponentType, PropMetadata};
component_metadata_with_props(
&SPINNER_RECIPE,
ComponentType::Svg,
"svg",
vec![
PropMetadata::new("size", "SpinnerSize").optional(),
PropMetadata::new("className", "string").optional(),
],
)
.with_svg_content(LOADER_CIRCLE_PATH)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spinner_recipe_generates_default() {
let style = spinner_recipe().resolve(SpinnerRecipeProps::default());
assert!(spinner_recipe().base.contains("animate-spin"));
assert_eq!(style.size(), SpinnerSize::Default);
assert!(style.size().classes().contains("size-4"));
}
#[test]
fn spinner_recipe_supports_sizes() {
for (size, expected) in [(SpinnerSize::Sm, "size-3"), (SpinnerSize::Lg, "size-6")] {
let style = spinner_recipe().resolve(SpinnerRecipeProps { size: Some(size) });
assert_eq!(style.size(), size);
assert!(style.size().classes().contains(expected));
}
}
#[test]
fn recipe_is_fully_const() {
const RECIPE: &SpinnerRecipe = &SPINNER_RECIPE;
const STYLE: SpinnerStyle = RECIPE.resolve(SpinnerRecipeProps {
size: Some(SpinnerSize::Lg),
});
assert_eq!(STYLE.size(), SpinnerSize::Lg);
}
}
spinner.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Spinner component implementation
use super::recipe::{SpinnerRecipeProps, SpinnerSize, SPINNER_RECIPE};
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Type-safe Spinner component using SpinnerRecipe.
///
/// This struct is fully stack-allocated with no heap allocations.
/// All fields use static string slices or references.
///
/// The Spinner displays an animated loading icon (lucide loader-circle).
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Spinner, SpinnerSize};
/// use proa_macros::html_sync;
///
/// html_sync! {
/// <div>
/// {Spinner {
/// size: Some(SpinnerSize::Lg),
/// ..Spinner::new()
/// }}
/// </div>
/// }.render(cx);
/// ```
pub struct Spinner {
pub size: Option<SpinnerSize>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
impl Spinner {
/// Create a new spinner with default settings
pub const fn new() -> Self {
Self {
size: None,
class: None,
id: None,
}
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Spinner {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = SPINNER_RECIPE.resolve(SpinnerRecipeProps { size: self.size });
html_sync! {
<svg
data-spinner
data-slot="spinner"
data-size={style.size().as_str()}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={proa_macros::text!("{} {}", style, self.class)}
role="status"
aria-label="Loading"
id={self.id}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
}
.render(cx)
}
}
impl Default for Spinner {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
#[test]
fn spinner_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Spinner::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<svg"));
assert!(html.contains("animate-spin"));
assert!(html.contains("size-4"));
assert!(html.contains("role=\"status\""));
assert!(html.contains("aria-label=\"Loading\""));
assert!(html.contains("data-slot=\"spinner\""));
assert!(html.contains("data-size=\"default\""));
assert!(html.contains("</svg>"));
}
#[test]
fn spinner_applies_sizes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Spinner {
size: Some(SpinnerSize::Lg),
..Spinner::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("size-6"));
}
#[test]
fn spinner_size_is_reasonable() {
assert!(std::mem::size_of::<Spinner>() < 64);
assert!(!std::mem::needs_drop::<Spinner>());
const _SPINNER: Spinner = Spinner::new();
}
}