Icon
Renders SVG icons using IconData structs.
Sizes
Icons come in five sizes.
Extra Small (12px)
Small (16px)
Medium (20px)
Large (24px)
Extra Large (32px)
Lucide Icons
Various Lucide icons rendered with the Icon component.
User
Settings
Search
Plus
X
Check
ChevronDown
ChevronRight
Loader
Custom Colors
Apply color classes to icons using the class prop.
Success
Error
Primary
Muted
FreeIcon
Typed icon rendering.
Install with the Proa CLI
$ proa ui add iconInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →icon.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Icon component implementation
#![allow(unused_must_use)]
use proa_core::{ClassList, UnsafeRaw, WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Trait for types that can provide SVG icon data.
///
/// This trait is implemented for `IconData` and can be implemented for
/// `icondata_core::IconData` to allow direct use of icons from the icondata ecosystem.
///
/// # Example implementing for icondata
///
/// ```ignore
/// use icondata_core::IconData as ExtIconData;
/// use proa_ui::IconDataRef;
///
/// impl IconDataRef for ExtIconData {
/// fn view_box(&self) -> Option<&'static str> { self.view_box }
/// fn fill(&self) -> Option<&'static str> { self.fill }
Icon source
Browse and copy the reviewed source included with this Free component.
icon.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Icon component implementation
#![allow(unused_must_use)]
use proa_core::{ClassList, UnsafeRaw, WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Trait for types that can provide SVG icon data.
///
/// This trait is implemented for `IconData` and can be implemented for
/// `icondata_core::IconData` to allow direct use of icons from the icondata ecosystem.
///
/// # Example implementing for icondata
///
/// ```ignore
/// use icondata_core::IconData as ExtIconData;
/// use proa_ui::IconDataRef;
///
/// impl IconDataRef for ExtIconData {
/// fn view_box(&self) -> Option<&'static str> { self.view_box }
/// fn fill(&self) -> Option<&'static str> { self.fill }
/// fn stroke(&self) -> Option<&'static str> { self.stroke }
/// fn stroke_width(&self) -> Option<&'static str> { self.stroke_width }
/// fn stroke_linecap(&self) -> Option<&'static str> { self.stroke_linecap }
/// fn stroke_linejoin(&self) -> Option<&'static str> { self.stroke_linejoin }
/// fn data(&self) -> &'static str { self.data }
/// }
/// ```
pub trait IconDataRef {
/// SVG viewBox attribute
fn view_box(&self) -> Option<&'static str>;
/// SVG fill attribute
fn fill(&self) -> Option<&'static str>;
/// SVG stroke attribute
fn stroke(&self) -> Option<&'static str>;
/// SVG stroke-width attribute
fn stroke_width(&self) -> Option<&'static str>;
/// SVG stroke-linecap attribute
fn stroke_linecap(&self) -> Option<&'static str>;
/// SVG stroke-linejoin attribute
fn stroke_linejoin(&self) -> Option<&'static str>;
/// Inner SVG content (paths, circles, etc.)
fn data(&self) -> &'static str;
}
/// SVG icon data structure.
///
/// This struct is layout-compatible with `icondata_core::IconData`. You can either:
/// 1. Use this struct directly with helper constructors like `IconData::lucide()`
/// 2. Use icons from the `icondata` crate by implementing `IconDataRef` for `icondata_core::IconData`
///
/// # Example with Lucide-style icon
///
/// ```ignore
/// use proa_ui::{Icon, IconData, IconSize};
///
/// // Simple: just provide the SVG path data
/// static CHEVRON: IconData = IconData::lucide(r#"<path d="m6 9 6 6 6-6"/>"#);
/// ```
///
/// # Example with custom icon (full control)
///
/// ```ignore
/// use proa_ui::{Icon, IconData, IconSize};
///
/// static MY_ICON: IconData = IconData {
/// view_box: Some("0 0 24 24"),
/// fill: Some("currentColor"),
/// data: r#"<path d="..."/>"#,
/// ..IconData::EMPTY
/// };
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IconData {
/// CSS style attribute
pub style: Option<&'static str>,
/// SVG x attribute
pub x: Option<&'static str>,
/// SVG y attribute
pub y: Option<&'static str>,
/// SVG width attribute
pub width: Option<&'static str>,
/// SVG height attribute
pub height: Option<&'static str>,
/// SVG viewBox attribute
pub view_box: Option<&'static str>,
/// SVG stroke-linecap attribute
pub stroke_linecap: Option<&'static str>,
/// SVG stroke-linejoin attribute
pub stroke_linejoin: Option<&'static str>,
/// SVG stroke-width attribute
pub stroke_width: Option<&'static str>,
/// SVG stroke attribute
pub stroke: Option<&'static str>,
/// SVG fill attribute
pub fill: Option<&'static str>,
/// Inner SVG content (paths, circles, etc.)
pub data: &'static str,
}
impl IconDataRef for IconData {
fn view_box(&self) -> Option<&'static str> {
self.view_box
}
fn fill(&self) -> Option<&'static str> {
self.fill
}
fn stroke(&self) -> Option<&'static str> {
self.stroke
}
fn stroke_width(&self) -> Option<&'static str> {
self.stroke_width
}
fn stroke_linecap(&self) -> Option<&'static str> {
self.stroke_linecap
}
fn stroke_linejoin(&self) -> Option<&'static str> {
self.stroke_linejoin
}
fn data(&self) -> &'static str {
self.data
}
}
impl IconDataRef for &'static IconData {
fn view_box(&self) -> Option<&'static str> {
(*self).view_box()
}
fn fill(&self) -> Option<&'static str> {
(*self).fill()
}
fn stroke(&self) -> Option<&'static str> {
(*self).stroke()
}
fn stroke_width(&self) -> Option<&'static str> {
(*self).stroke_width()
}
fn stroke_linecap(&self) -> Option<&'static str> {
(*self).stroke_linecap()
}
fn stroke_linejoin(&self) -> Option<&'static str> {
(*self).stroke_linejoin()
}
fn data(&self) -> &'static str {
(*self).data()
}
}
#[cfg(feature = "icondata")]
impl IconDataRef for icondata_core::IconData {
fn view_box(&self) -> Option<&'static str> {
self.view_box
}
fn fill(&self) -> Option<&'static str> {
self.fill
}
fn stroke(&self) -> Option<&'static str> {
self.stroke
}
fn stroke_width(&self) -> Option<&'static str> {
self.stroke_width
}
fn stroke_linecap(&self) -> Option<&'static str> {
self.stroke_linecap
}
fn stroke_linejoin(&self) -> Option<&'static str> {
self.stroke_linejoin
}
fn data(&self) -> &'static str {
self.data
}
}
#[cfg(feature = "icondata")]
impl IconDataRef for &'static icondata_core::IconData {
fn view_box(&self) -> Option<&'static str> {
(*self).view_box()
}
fn fill(&self) -> Option<&'static str> {
(*self).fill()
}
fn stroke(&self) -> Option<&'static str> {
(*self).stroke()
}
fn stroke_width(&self) -> Option<&'static str> {
(*self).stroke_width()
}
fn stroke_linecap(&self) -> Option<&'static str> {
(*self).stroke_linecap()
}
fn stroke_linejoin(&self) -> Option<&'static str> {
(*self).stroke_linejoin()
}
fn data(&self) -> &'static str {
(*self).data()
}
}
impl IconData {
/// Empty icon data - use with struct update syntax: `..IconData::EMPTY`
pub const EMPTY: Self = Self {
style: None,
x: None,
y: None,
width: None,
height: None,
view_box: None,
stroke_linecap: None,
stroke_linejoin: None,
stroke_width: None,
stroke: None,
fill: None,
data: "",
};
/// Create a Lucide-style icon with standard defaults.
///
/// This sets up the icon with:
/// - viewBox: "0 0 24 24"
/// - stroke: "currentColor"
/// - fill: "none"
/// - stroke-width: "2"
/// - stroke-linecap: "round"
/// - stroke-linejoin: "round"
///
/// # Example
///
/// ```ignore
/// use proa_ui::IconData;
///
/// static LU_CHEVRON_DOWN: IconData = IconData::lucide(r#"<path d="m6 9 6 6 6-6"/>"#);
/// static LU_CHECK: IconData = IconData::lucide(r#"<path d="M20 6 9 17l-5-5"/>"#);
/// static LU_X: IconData = IconData::lucide(r#"<path d="M18 6 6 18"/><path d="m6 6 12 12"/>"#);
/// ```
pub const fn lucide(data: &'static str) -> Self {
Self {
style: None,
x: None,
y: None,
width: None,
height: None,
view_box: Some("0 0 24 24"),
stroke_linecap: Some("round"),
stroke_linejoin: Some("round"),
stroke_width: Some("2"),
stroke: Some("currentColor"),
fill: Some("none"),
data,
}
}
/// Create a filled icon (no stroke, uses fill).
///
/// # Example
///
/// ```ignore
/// use proa_ui::IconData;
///
/// static CIRCLE_FILLED: IconData = IconData::filled(r#"<circle cx="12" cy="12" r="10"/>"#);
/// ```
pub const fn filled(data: &'static str) -> Self {
Self {
style: None,
x: None,
y: None,
width: None,
height: None,
view_box: Some("0 0 24 24"),
stroke_linecap: None,
stroke_linejoin: None,
stroke_width: None,
stroke: None,
fill: Some("currentColor"),
data,
}
}
}
/// Icon size variants
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IconSize {
/// Extra small - 12px
Xs,
/// Small - 16px
Sm,
/// Medium - 20px (default)
#[default]
Md,
/// Large - 24px
Lg,
/// Extra large - 32px
Xl,
}
impl IconSize {
/// Get the size in pixels as a string
pub const fn as_px(self) -> &'static str {
match self {
IconSize::Xs => "12",
IconSize::Sm => "16",
IconSize::Md => "20",
IconSize::Lg => "24",
IconSize::Xl => "32",
}
}
/// Get the Tailwind size class
pub const fn as_class(self) -> &'static str {
match self {
IconSize::Xs => "size-3",
IconSize::Sm => "size-4",
IconSize::Md => "size-5",
IconSize::Lg => "size-6",
IconSize::Xl => "size-8",
}
}
}
/// Icon component - renders SVG icons
///
/// Works with any type implementing `IconDataRef`, including:
/// - `proa_ui::IconData` - for custom icons or using `IconData::lucide()`
/// - `icondata_core::IconData` - after implementing `IconDataRef` for it
///
/// # Example with IconData
///
/// ```ignore
/// use proa_ui::{Icon, IconData, IconSize};
///
/// static CHEVRON: IconData = IconData::lucide(r#"<path d="m6 9 6 6 6-6"/>"#);
///
/// html_sync! {
/// {Icon { icon: &CHEVRON, size: Some(IconSize::Md), class: None }}
/// }
/// ```
///
/// # Example with icondata crate
///
/// ```ignore
/// use icondata_lu::LuUser;
/// use proa_ui::{Icon, IconSize};
///
/// // After implementing IconDataRef for icondata_core::IconData
/// html_sync! {
/// {Icon { icon: LuUser, size: Some(IconSize::Md), class: None }}
/// }
/// ```
pub struct Icon<I: IconDataRef> {
/// The icon data (implements IconDataRef)
pub icon: I,
/// Size of the icon
pub size: Option<IconSize>,
/// Additional CSS classes
pub class: Option<&'static str>,
}
impl Default for Icon<&'static IconData> {
fn default() -> Self {
static EMPTY_ICON: IconData = IconData {
style: None,
x: None,
y: None,
width: None,
height: None,
view_box: Some("0 0 24 24"),
stroke_linecap: None,
stroke_linejoin: None,
stroke_width: None,
stroke: None,
fill: None,
data: "",
};
Self {
icon: &EMPTY_ICON,
size: None,
class: None,
}
}
}
impl<I: IconDataRef, B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Icon<I> {
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let size = self.size.unwrap_or_default();
let size_px = size.as_px();
let classes = ClassList::<2>::new().add("shrink-0").add_opt(self.class);
let view_box = self.icon.view_box().unwrap_or("0 0 24 24");
let fill = self.icon.fill().unwrap_or("none");
let stroke = self.icon.stroke().unwrap_or("currentColor");
if let Some(stroke_width) = self.icon.stroke_width() {
if let Some(stroke_linecap) = self.icon.stroke_linecap() {
if let Some(stroke_linejoin) = self.icon.stroke_linejoin() {
html_sync! {
<svg
data-icon
xmlns="http://www.w3.org/2000/svg"
width={size_px}
height={size_px}
viewBox={view_box}
fill={fill}
stroke={stroke}
stroke-width={stroke_width}
stroke-linecap={stroke_linecap}
stroke-linejoin={stroke_linejoin}
class={classes}
>
{proa_core::foreign_content_ok(UnsafeRaw(self.icon.data()))} // proa-perf-ok: trusted static markup emitted through the low-level writer
</svg>
}
.render(cx)?;
return Ok(());
}
}
}
html_sync! {
<svg
data-icon
xmlns="http://www.w3.org/2000/svg"
width={size_px}
height={size_px}
viewBox={view_box}
fill={fill}
stroke={stroke}
class={classes}
>
{proa_core::foreign_content_ok(UnsafeRaw(self.icon.data()))} // proa-perf-ok: trusted static markup emitted through the low-level writer
</svg>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
static TEST_ICON: IconData = IconData {
style: None,
x: None,
y: None,
width: None,
height: None,
view_box: Some("0 0 24 24"),
stroke_linecap: Some("round"),
stroke_linejoin: Some("round"),
stroke_width: Some("2"),
stroke: Some("currentColor"),
fill: Some("none"),
data: r#"<path d="m6 9 6 6 6-6"/>"#,
};
#[test]
fn icon_renders_svg() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Icon {
icon: &TEST_ICON,
size: Some(IconSize::Md),
class: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<svg"));
assert!(html.contains("</svg>"));
assert!(html.contains(r#"viewBox="0 0 24 24""#));
}
#[test]
fn icon_uses_size() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Icon {
icon: &TEST_ICON,
size: Some(IconSize::Lg),
class: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(r#"width="24""#));
assert!(html.contains(r#"height="24""#));
}
#[test]
fn icon_includes_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Icon {
icon: &TEST_ICON,
size: Some(IconSize::Md),
class: Some("text-red-500"),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("text-red-500"));
}
#[test]
fn icon_renders_inner_data() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Icon {
icon: &TEST_ICON,
size: None,
class: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(r#"<path d="m6 9 6 6 6-6"/>"#));
}
#[test]
fn icon_size_as_px() {
assert_eq!(IconSize::Xs.as_px(), "12");
assert_eq!(IconSize::Sm.as_px(), "16");
assert_eq!(IconSize::Md.as_px(), "20");
assert_eq!(IconSize::Lg.as_px(), "24");
assert_eq!(IconSize::Xl.as_px(), "32");
}
#[test]
fn icon_size_as_class() {
assert_eq!(IconSize::Xs.as_class(), "size-3");
assert_eq!(IconSize::Sm.as_class(), "size-4");
assert_eq!(IconSize::Md.as_class(), "size-5");
assert_eq!(IconSize::Lg.as_class(), "size-6");
assert_eq!(IconSize::Xl.as_class(), "size-8");
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
use super::{Icon, IconDataRef};
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// Icon is purely decorative SVG — it renders nothing in markdown.
impl<Loader: ::proa_core::DataLoader, I: IconDataRef, B: WriteBuf> MdRenderSync<Loader, B>
for Icon<I>
{
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::super::{IconData, IconSize};
use super::*;
static TEST_ICON: IconData = IconData::lucide(r#"<path d="m6 9 6 6 6-6"/>"#);
#[test]
fn icon_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Icon {
icon: &TEST_ICON,
size: Some(IconSize::Md),
class: None,
}
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Icon component - renders SVG icons
//!
//! This component provides an `IconData` struct that is layout-compatible
//! with `icondata_core::IconData`, so you can use icons from the `icondata`
//! crate ecosystem (Lucide, Heroicons, Font Awesome, etc.) by adding those
//! crates as dependencies.
//!
//! # Usage with icondata crate
//!
//! ```ignore
//! // In Cargo.toml: icondata_lu = "0.1"
//! use icondata_lu::LuChevronDown;
//! use proa_ui::{Icon, IconSize};
//!
//! html! {
//! {Icon { icon: LuChevronDown, size: Some(IconSize::Md), class: None }}
//! }
//! ```
//!
//! # Usage with custom icons
//!
//! ```ignore
//! use proa_ui::{Icon, IconData, IconSize};
//!
//! static MY_ICON: IconData = IconData {
//! style: None, x: None, y: None, width: None, height: None,
//! view_box: Some("0 0 24 24"),
//! stroke_linecap: Some("round"),
//! stroke_linejoin: Some("round"),
//! stroke_width: Some("2"),
//! stroke: Some("currentColor"),
//! fill: Some("none"),
//! data: r#"<path d="m6 9 6 6 6-6"/>"#,
//! };
//! ```
#[allow(clippy::module_inception)]
mod icon;
mod md;
pub use icon::{Icon, IconData, IconDataRef, IconSize};