gpuicn Components

Component 06 of 60

Badge

Labels and unread or mention counts in a compact pill.

site/src/previews/badge.rs

Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.

Badge, running in the browser. The second tab shows the preview's own source, which includes its layout and demo state. For the smallest working example, see Usage below. Download the whole showcase for its imports and helpers.

Installation

After the one-time setup, run the command from your app root. The CLI writes the modules and keeps src/ui/mod.rs in step.

gpuicn add badge

If you prefer to copy the files by hand, declare them yourself:

// src/ui/mod.rs
pub mod theme;
pub mod badge;

// src/main.rs
mod ui;

Usage

Put this in a view module and return example(mentions) from its render method.

use crate::ui::badge::*;
use gpui_kit::IntoElement;

fn example(mentions: usize) -> impl IntoElement {
    Badge::count(mentions, 99).variant(BadgeVariant::Destructive)
}

API reference

pub fn new(label: impl Into<SharedString>) -> Self

Creates a `Badge` showing `label`.

pub fn count(count: usize, max: usize) -> Self

Creates a count `Badge`, showing `max+` once `count` exceeds `max`.

pub fn variant(mut self, variant: BadgeVariant) -> Self

Selects the Nova color treatment.

pub fn icon(mut self, icon: LucideIcon) -> Self

Adds a leading Lucide icon.

pub fn format_count(count: usize, max: usize) -> SharedString

Formats an unread or mention count, capping it at `max+`.

Installed source, 141 lines
//! Nova-styled badges for labels and counts.

use gpui_icons::{LucideIcon, lucide};
use gpui_kit::{
    App, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
    prelude::FluentBuilder as _, px, relative,
};

use super::theme::UiTheme;

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
/// Nova badge color treatments.
pub enum BadgeVariant {
    #[default]
    /// Primary fill, for the most important count or label.
    Default,
    /// Neutral secondary fill.
    Secondary,
    /// Tinted destructive treatment, for errors and urgent counts.
    Destructive,
    /// Transparent with a border.
    Outline,
    /// Text only, with no fill or border.
    Ghost,
}

/// A small, noninteractive pill that labels or counts something.
#[derive(IntoElement)]
pub struct Badge {
    style: gpui_kit::StyleRefinement,
    label: SharedString,
    icon: Option<LucideIcon>,
    variant: BadgeVariant,
}

impl Badge {
    /// Creates a `Badge` showing `label`.
    pub fn new(label: impl Into<SharedString>) -> Self {
        Self {
            style: gpui_kit::StyleRefinement::default(),
            label: label.into(),
            icon: None,
            variant: BadgeVariant::Default,
        }
    }

    /// Creates a count `Badge`, showing `max+` once `count` exceeds `max`.
    pub fn count(count: usize, max: usize) -> Self {
        Self::new(format_count(count, max))
    }

    /// Selects the Nova color treatment.
    pub fn variant(mut self, variant: BadgeVariant) -> Self {
        self.variant = variant;
        self
    }

    /// Adds a leading Lucide icon.
    pub fn icon(mut self, icon: LucideIcon) -> Self {
        self.icon = Some(icon);
        self
    }
}

/// Formats an unread or mention count, capping it at `max+`.
pub fn format_count(count: usize, max: usize) -> SharedString {
    if count > max {
        format!("{max}+").into()
    } else {
        count.to_string().into()
    }
}

impl RenderOnce for Badge {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = UiTheme::read(cx);
        let colors = theme.colors;
        let (background, foreground, border) = match self.variant {
            BadgeVariant::Default => (colors.primary, colors.primary_foreground, None),
            BadgeVariant::Secondary => (colors.secondary, colors.secondary_foreground, None),
            BadgeVariant::Destructive => {
                (colors.destructive.opacity(0.1), colors.destructive, None)
            }
            BadgeVariant::Outline => (
                colors.background.opacity(0.),
                colors.foreground,
                Some(colors.border),
            ),
            BadgeVariant::Ghost => (colors.background.opacity(0.), colors.foreground, None),
        };
        let root = div()
            .flex()
            .flex_shrink_0()
            .items_center()
            .justify_center()
            .gap(theme.space(1.))
            .h(theme.space(5.))
            .min_w(theme.space(5.))
            .px(theme.space(2.))
            .rounded(px(9999.))
            .border_1()
            .border_color(border.unwrap_or(background.opacity(0.)))
            .bg(background)
            .text_color(foreground)
            .font_family(theme.fonts.body.clone())
            .font_weight(FontWeight::MEDIUM)
            .text_size(theme.text(12.))
            .line_height(relative(1.))
            .whitespace_nowrap()
            .overflow_hidden()
            .when_some(self.icon, |root, icon| {
                root.child(
                    lucide(icon)
                        .size(theme.space(3.))
                        .flex_shrink_0()
                        .text_color(foreground),
                )
            })
            .child(self.label);
        super::theme::apply_style(root, &self.style)
    }
}

impl Styled for Badge {
    fn style(&mut self) -> &mut gpui_kit::StyleRefinement {
        &mut self.style
    }
}

#[cfg(test)]
mod tests {
    use super::format_count;

    #[test]
    fn counts_cap_at_max() {
        assert_eq!(format_count(0, 99).as_ref(), "0");
        assert_eq!(format_count(99, 99).as_ref(), "99");
        assert_eq!(format_count(100, 99).as_ref(), "99+");
    }
}

Platform notes

Visual reference: shadcn/ui 4.19.0 badge, Neutral Nova. Implementation: crates/ui/registry/badge/badge.rs, using GPUI Kit 0.8.1.

gpuicn ports the 20px pill with medium 12px text and the Default, Secondary, Destructive, Outline and Ghost treatments. Destructive uses Nova's tinted destructive fill rather than a solid one. Badge::count formats unread and mention counts and caps them as 99+; Badge::icon adds a leading Lucide icon.

shadcn's asChild link badge has no equivalent: a badge is noninteractive. Wrap it in a Button when it must act. The label is read as plain text, so give counts context in surrounding text, such as a sidebar item's accessible name.