s (border and inner surface); the glow is a pseudo-element that ignores the pointer and is not announced.
- The glow strengthens on :focus-within, so keyboard users get the same emphasis as hover.
- active is visual only: if it means "selected", also expose it on the control (aria-pressed, a radio, or text).
## Motion
- A conic gradient arc covering about half of the circle rotates around the border once every `duration` s (6), linear; a blurred copy (`blur` px, 14) glows behind it.
- On hover, focus inside, or when active, the glow rises from `restOpacity` (0.25) to `hoverOpacity` (0.75) and blurs 1.4x more, over 0.4 s. In dark mode the resting glow is 1.6x stronger.
- Rotation pauses while the card is off-screen.
- Reduced motion: the arc stops rotating and the glow change is instant. Browsers without @property support show the arc without rotation.
## Performance
- CSS only, animating one registered custom property; an IntersectionObserver pauses it off-screen. The blurred glow costs paint time, so keep it to a few cards per screen.
## Notes for AI
- Content padding goes in innerClassName (the inner surface); className styles the outer border box (radius, width).
- The outer box is rounded-xl; change it with className (rounded-2xl) and the inner surface follows (rounded-[inherit]).
- colors defaults to the theme's --chart-1, --chart-4, --chart-5; pass two or more CSS colors for brand colors.
- The inner surface is bg-card; override it with innerClassName if the card sits on a different surface.
## FAQ
**Why does the arc not rotate in my browser?**
Rotation animates a registered CSS custom property (@property). Browsers without it show the arc in a fixed position.
**Can I keep the glow on without hover?**
Yes, pass active. It uses the same strength as hover.
## Related
- border-beam: https://goodui.dev/components/cards/border-beam
- spotlight-card: https://goodui.dev/components/cards/spotlight-card
- card: https://goodui.dev/components/core/card
- aurora: https://goodui.dev/components/backgrounds/aurora
Last updated: 2026-11-02
---
# Progressive blur
> Blur that grows stronger toward the edge of a scroll container, built from stacked backdrop-filter layers; shown on a scrolling bookings list.
Progressive blur for React and shadcn: gradient blur at the edge of a scroll list
Page: https://goodui.dev/components/cards/progressive-blur
## Install
```bash
npx shadcn@latest add @goodui/progressive-blur
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/progressive-blur.json`. The file lands in `components/goodui/progressive-blur.tsx`.
## When to use
- The bottom (or top) edge of a scrolling list, so items soften away instead of being cut off: bookings, messages, notifications.
- Under a sticky header or floating toolbar, where content should blur as it scrolls beneath.
- Image cards with a caption at the bottom, where a blur band keeps the text readable over the photo.
## When not to use
- Hinting that a horizontal row continues: a plain mask fade is cheaper; marquee has one built in for moving rows.
- Long, fast-scrolling pages on low-end phones: stacked backdrop-filter layers cost GPU time. Use a gradient fade (fadeColor alone, or a CSS mask) there.
- Hiding content for privacy or paywalls: blur is not access control.
## Pick by what matters
| Need | Use |
| --- | --- |
| Bottom of a scroll list | side="bottom" (default) |
| Under a sticky header | side="top" |
| Also fade into the card color | fadeColor="var(--card)" |
| Smoother | layers={8} |
| Cheaper | layers={3} |
| Fixed height band | size={96} (px) or size="6rem" |
## Usage
```tsx
import { ProgressiveBlur } from "@/components/goodui/progressive-blur"
const bookings = [
{ time: "09:00", client: "Maya Chen", service: "Deep clean · 3 bed" },
{ time: "11:30", client: "Leo Park", service: "Move-out clean" },
{ time: "13:00", client: "Ana Silva", service: "Windows" },
{ time: "15:30", client: "Sam Ortiz", service: "Office · weekly" },
{ time: "17:00", client: "Iris Novak", service: "Carpet steam" },
]
export function TodayList() {
return (
{bookings.map((b) => (
{b.time}
{b.client}
{b.service}
))}
)
}
```
### Blur under a sticky header
```tsx
```
## API
### ProgressiveBlur
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| side | `"top" \| "bottom" \| "left" \| "right"` | `"bottom"` | Edge the blur sits on; it grows toward that edge. |
| size | `string \| number` | `"35%"` | Size of the blurred band (any CSS length). |
| blur | `number` | `8` | Blur in px at the very edge. |
| layers | `number` | `6` | Number of stacked layers; more is smoother, fewer is cheaper. |
| fadeColor | `string` | — | Also fade to this color at the edge (e.g. "var(--card)"). Off when undefined. |
Also accepts every
attribute.
## Accessibility
- The blur is aria-hidden and has pointer-events: none, so content under it stays clickable and scrollable.
- Leave scroll padding (pb-16 or similar) so the last item can scroll fully out from under the blur.
- Give the scroll container tabIndex={0} and an aria-label (unless it already holds focusable items), so keyboard users can scroll it.
## Motion
- No animation: the blur is static and only changes as content scrolls beneath it, so reduced motion needs no special handling.
## Performance
- Each layer is one backdrop-filter element; the blur doubles per layer up to `blur` px (8). Fewer layers are cheaper, more are smoother (default 6).
- Keep it to a few instances per screen, especially on mobile.
## Notes for AI
- Place it inside a relative (and usually overflow-hidden) wrapper, as a sibling of the scroll container, not inside the scrolling element.
- size accepts a number (px) or any CSS length; blur is px at the very edge.
- side uses physical edges (top, bottom, left, right); it does not flip in RTL.
- fadeColor should match the surface behind the list (var(--card), var(--background)).
- It renders at z-10; raise sticky headers above it if they should stay sharp.
## FAQ
**Why use several layers instead of one blur?**
A single backdrop-filter has one strength. Stacking masked layers whose blur doubles toward the edge makes the blur grow smoothly.
**Browsers without backdrop-filter?**
The layers then do nothing; add fadeColor so the edge still fades.
## Related
- marquee: https://goodui.dev/components/backgrounds/marquee
- card: https://goodui.dev/components/core/card
- animated-list: https://goodui.dev/components/lists/animated-list
- spotlight-card: https://goodui.dev/components/cards/spotlight-card
Last updated: 2026-11-02
---
# Expandable card
> A card expands in place on a spring to reveal details, such as a staff profile with bookable slots or a plan's full feature list.
Expandable card for React and shadcn: card that expands in place to show details
Page: https://goodui.dev/components/cards/expandable-card
## Install
```bash
npx shadcn@latest add @goodui/expandable-card
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/expandable-card.json`. The file lands in `components/goodui/expandable-card.tsx`.
## When to use
- Staff or service cards that open to show bookable time slots, prices or a bio, without leaving the list.
- Pricing plans whose full feature list opens on demand.
- Settings sections and FAQ-style cards where each card is independent (several can be open).
## When not to use
- A list where only one section should be open at a time: use accordion-height.
- Details that deserve their own focus and backdrop (a full profile, a long form): use morphing-dialog or drawer-spring.
- Expanding table rows: use expandable-row-list.
## Pick by what matters
| Need | Use |
| --- | --- |
| Open by default | defaultOpen |
| Open state in your own state or URL | open={…} onOpenChange={…} |
| Custom indicator instead of the chevron | chevron={false} on ExpandableCardTrigger |
## Usage
```tsx
import { ExpandableCard, ExpandableCardContent, ExpandableCardTrigger } from "@/components/goodui/expandable-card"
const slots = ["09:00", "10:30", "13:00", "15:30"]
export function StylistCard() {
return (
Maya Chen
Senior stylist · color specialist
Next free slots on Friday:
{slots.map((t) => (
{t}
))}
)
}
```
### Controlled, one card open at a time
```tsx
const [openId, setOpenId] = React.useState
(null)
{plans.map((p) => (
setOpenId(o ? p.id : null)}>
{p.name} · {p.price}
{p.features.map((f) => {f} )}
))}
```
## API
### ExpandableCard
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| open | `boolean` | — | Controlled open state. |
| defaultOpen | `boolean` | `false` | Initial open state when uncontrolled. |
| onOpenChange | `(open: boolean) => void` | — | Called when the trigger or Esc opens or closes the card. |
Also accepts every attribute.
### ExpandableCardTrigger
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| chevron | `boolean` | `true` | Show the rotating chevron on the right. |
Also accepts every
attribute.
### ExpandableCardContent
Also accepts every attribute.
## Keyboard
| Keys | Action |
| --- | --- |
| Enter / Space | On the trigger: opens or closes the card (native
). |
| Escape | Closes the card while focus is anywhere inside it; the event does not bubble further. |
| Tab | Moves through the trigger and then the content's controls when open. |
## Accessibility
- ExpandableCardTrigger is a real with aria-expanded and aria-controls pointing to the content.
- ExpandableCardContent has role="region" named by the trigger (aria-labelledby) and is removed from the DOM when closed, so hidden controls are never tabbable.
- When Escape closes the card from inside the content, focus returns to the trigger.
- The chevron is aria-hidden; the trigger's text is its accessible name, so keep it descriptive.
## Motion
- The content grows to its natural height on a spring (stiffness 260, damping 32, mass 0.8) while fading in over 0.2 s; the inner content then rises 6px and fades in over 0.25 s after 0.05 s.
- The chevron rotates 180° on a spring (stiffness 300, damping 24). The card's shadow deepens over 300 ms when open.
- Closing runs the same animation in reverse.
- Reduced motion: content and chevron change instantly.
## Notes for AI
- Always compose the three parts: ExpandableCard > ExpandableCardTrigger + ExpandableCardContent. The parts throw outside an ExpandableCard.
- Padding is built in (p-4 on the trigger, px-4 pb-4 on the content); do not add padding to the card itself.
- Trigger children go in a flexible span next to the chevron; use block spans for title and subtitle lines.
- Do not put another button inside ExpandableCardTrigger: it is already a .
- For accordion behavior (one open), control open from the parent as in the example.
## FAQ
**Can several cards be open at once?**
Yes, each card has its own state. Control open from a parent to allow only one.
**Does it animate to height: auto?**
Yes. The content's height animates with motion from 0 to auto, so it works with content of any size.
## Related
- accordion-height: https://goodui.dev/components/lists/accordion-height
- expandable-row-list: https://goodui.dev/components/lists/expandable-row-list
- morphing-dialog: https://goodui.dev/components/overlays/morphing-dialog
- card: https://goodui.dev/components/core/card
Last updated: 2026-11-02
---
# Dot grid
> A dot or line grid background with a radial, top or edge fade and an optional glow that follows the pointer. Pure CSS.
Dot grid background for React and shadcn: dot and grid pattern with fade and cursor glow
Page: https://goodui.dev/components/backgrounds/dot-grid
## Install
```bash
npx shadcn@latest add @goodui/dot-grid
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/dot-grid.json`. The file lands in `components/goodui/dot-grid.tsx`.
## When to use
- Behind a hero, pricing header or empty state, to add texture without color.
- Developer-tool and SaaS pages that want a blueprint or graph-paper feel (variant="lines").
- Interactive sections where the grid should brighten around the cursor (highlight).
## When not to use
- Moving texture: use flickering-grid (canvas) or particles.
- Soft colored light instead of a pattern: use aurora.
- Behind dense text or tables: the pattern makes small text harder to read. Use a plain bg-muted surface.
## Pick by what matters
| Need | Use |
| --- | --- |
| Dots fading out from the center | variant="dots" fade="radial" (defaults) |
| Graph-paper lines | variant="lines" gap={32} |
| Pattern under a header that fades downward | fade="top" |
| Soft edges on all sides | fade="edges" |
| Glow following the cursor | highlight |
## Usage
```tsx
import { DotGrid } from "@/components/goodui/dot-grid"
export function Hero() {
return (
Run your cleaning business from one screen
Bookings, crews, invoices and reminders.
)
}
```
### Line grid under a pricing header
```tsx
```
### Brand-tinted dots
```tsx
```
## API
### DotGrid
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| variant | `"dots" \| "lines"` | `"dots"` | "dots" is a dot pattern; "lines" a square line grid. |
| gap | `number` | `22` | Distance between dots or lines, in px. |
| size | `number` | `1` | Dot radius or line width, in px. |
| fade | `"none" \| "bottom" \| "top" \| "radial" \| "edges"` | `"radial"` | Where the pattern fades out: "radial" \| "top" \| "bottom" \| "edges" \| "none". |
| highlight | `boolean` | `false` | Brighten the pattern around the pointer (listens on the parent element). |
| highlightColor | `string` | `"color-mix(in oklch, var(--chart-1) 90%, transparent)"` | Color of the pattern inside the pointer highlight. Defaults to the theme's --chart-1. |
| highlightRadius | `number` | `180` | Radius of the pointer highlight, in px. |
Also accepts every attribute.
## Accessibility
- The grid is aria-hidden and has pointer-events: none, so it is never announced and never blocks clicks.
- The default color is the foreground at low opacity (22% for dots, 9% for lines), so it follows light and dark themes.
## Motion
- The pattern itself does not move.
- With highlight, a brighter copy of the pattern shows in a circle (`highlightRadius` px, 180) around the pointer and fades in and out over 0.4 s.
- Reduced motion: the highlight appears and disappears without the fade.
## Performance
- The pattern is CSS gradients. The highlight listens to pointer moves on the parent element and updates two CSS variables once per animation frame; React does not re-render.
## Notes for AI
- Place it as the first child of a relative (usually overflow-hidden) parent, and give the content after it position: relative so it paints above.
- highlight listens on the parent element, so the glow works across the whole section, not only over the grid.
- gap, size and highlightRadius are px; color is any CSS color.
- fade values: radial, top, bottom, edges, none.
## FAQ
**Does it work in dark mode?**
Yes. The default color mixes the theme's --foreground with transparency, so it adapts to light and dark.
**Why do I not see the cursor glow?**
highlight is off by default and the glow is drawn only while the pointer is over the parent element. Check that the parent is the section you hover.
## Related
- flickering-grid: https://goodui.dev/components/backgrounds/flickering-grid
- aurora: https://goodui.dev/components/backgrounds/aurora
- particles: https://goodui.dev/components/backgrounds/particles
- spotlight-card: https://goodui.dev/components/cards/spotlight-card
Last updated: 2026-11-02
---
# Flickering grid
> A canvas grid of small squares that flicker in opacity, follows the theme color, is sharp on retina and pauses off-screen.
Flickering grid background for React and shadcn: animated canvas squares
Page: https://goodui.dev/components/backgrounds/flickering-grid
## Install
```bash
npx shadcn@latest add @goodui/flickering-grid
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/flickering-grid.json`. The file lands in `components/goodui/flickering-grid.tsx`.
## When to use
- Hero and CTA sections of developer tools and AI products that want a living, digital texture.
- Behind a status or "system online" panel, where subtle flicker suggests activity.
- Cards and banners that should follow the theme color, including after a light/dark switch.
## When not to use
- A still pattern: use dot-grid, which is pure CSS and costs nothing at runtime.
- Soft, calm motion: use aurora.
- Behind long reading content or data tables: constant flicker pulls the eye. Use dot-grid with fade.
## Pick by what matters
| Need | Use |
| --- | --- |
| Barely-there texture | maxOpacity={0.12} flickerChance={0.15} |
| Brand colored | color="var(--primary)" |
| Chunkier pixels | squareSize={6} gap={4} |
| Cheaper on low-end devices | fps={15} |
| Freeze it | paused |
## Usage
```tsx
import { FlickeringGrid } from "@/components/goodui/flickering-grid"
export function ApiCta() {
return (
Sync bookings with your own app
REST API and webhooks on every plan.
)
}
```
## API
### FlickeringGrid
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| squareSize | `number` | `4` | Side of each square, in px. |
| gap | `number` | `6` | Space between squares, in px. |
| flickerChance | `number` | `0.3` | Share of squares that change per second (0–1). |
| maxOpacity | `number` | `0.25` | Highest square opacity (0–1). |
| fps | `number` | `30` | Frames per second; flicker reads fine at 24–30 and costs half of 60. |
| paused | `boolean` | `false` | Stop flickering and keep the current frame. |
Also accepts every
attribute.
## Accessibility
- The canvas wrapper is aria-hidden and has pointer-events: none.
- Keep maxOpacity low behind text; the default is 0.25.
## Motion
- Each frame, a share of squares (`flickerChance` per second, 0.3) picks a new random opacity between 0 and `maxOpacity`. It draws at `fps` frames per second (30).
- It pauses while off-screen, while the tab is hidden and when paused is set.
- Reduced motion: one still frame is drawn and nothing animates.
## Performance
- Canvas 2D, sized to the container with devicePixelRatio (capped at 2) so squares stay sharp on retina screens. A ResizeObserver redraws on resize.
- The color is resolved once and re-read when the class, style or data-theme attribute of changes or the system color scheme changes, not every frame.
## Notes for AI
- Place it inside a relative parent; it fills it with absolute inset-0. Give the content after it position: relative.
- color accepts any CSS color or variable (oklch, var(--primary)); it is resolved for canvas automatically.
- squareSize and gap are px; flickerChance and maxOpacity are 0 to 1.
- To fade the edges, add a mask with className, as in the usage example; there is no fade prop.
## FAQ
**Does it follow dark mode?**
Yes. With the default var(--foreground), or any theme variable, it re-reads the color when the theme class on changes.
**Does it keep running when nobody sees it?**
No. It stops while off-screen, in a hidden tab and when paused, and draws at 30 fps by default. Lower fps for even less work.
## Related
- dot-grid: https://goodui.dev/components/backgrounds/dot-grid
- particles: https://goodui.dev/components/backgrounds/particles
- aurora: https://goodui.dev/components/backgrounds/aurora
- glow-border: https://goodui.dev/components/cards/glow-border
Last updated: 2026-11-02
---
# Aurora
> Soft colored light drifts slowly behind a section, with light and dark palettes and only transform animations for low CPU.
Aurora background for React and shadcn: soft animated gradient mesh
Page: https://goodui.dev/components/backgrounds/aurora
## Install
```bash
npx shadcn@latest add @goodui/aurora
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/aurora.json`. The file lands in `components/goodui/aurora.tsx`.
## When to use
- Hero sections and sign-up pages that need color and calm motion behind the headline.
- Behind glass UI (liquid-glass-button), which needs something colorful to blur.
- Pricing or CTA banners that should follow the theme (palette="brand").
## When not to use
- Behind data-dense app screens: moving color behind numbers is distracting. Use a plain background or dot-grid.
- A crisp, technical texture: use dot-grid or flickering-grid.
- Text that needs a gradient: use gradient-text.
## Pick by what matters
| Need | Use |
| --- | --- |
| Cool teal and violet | palette="aurora" (default) |
| Warm peach and pink | palette="sunset" |
| Calm blues | palette="ocean" |
| Follow the theme's chart colors | palette="brand" |
| Exact colors in both themes | colors={["#…", "#…", "#…", "#…"]} |
| Quieter | intensity={0.5} speed={0.6} |
## Usage
```tsx
import { Aurora } from "@/components/goodui/aurora"
export function SignupHero() {
return (
Start taking bookings today
14-day free trial. No card needed.
)
}
```
### Sunset palette behind a glass toolbar
```tsx
```
## API
### Aurora
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| palette | `"aurora" \| "sunset" \| "ocean" \| "brand"` | `"aurora"` | Theme-aware preset; "brand" mixes the theme's chart colors. |
| colors | `[string, string, string, string]` | — | Custom colors for the four blobs (overrides the palette in both themes). |
| speed | `number` | `1` | Speed multiplier; 1 is a slow drift. |
| intensity | `number` | `0.9` | Blob opacity, 0–1. |
Also accepts every
attribute.
## Accessibility
- The layer is aria-hidden and has pointer-events: none.
- Light-mode palettes are pale and dark-mode palettes deep, so foreground text keeps its usual color on top. Check contrast yourself with custom colors or high intensity.
## Motion
- Four radial-gradient blobs drift, scale and alternate direction on ease-in-out loops of 18, 22, 26 and 20 s, divided by `speed`.
- Only transform is animated; the blobs are soft gradients, not filter: blur.
- It pauses while off-screen and while the tab is hidden.
- Reduced motion: the blobs stay still; the colors remain.
## Performance
- CSS keyframes on four elements, transform only, so the compositor does the work and no JavaScript runs per frame.
## Notes for AI
- Place it inside a relative overflow-hidden parent; it fills it with absolute inset-0. Give the content after it position: relative.
- colors must be exactly four CSS colors and replaces the palette in both light and dark.
- speed is a multiplier (2 is twice as fast); intensity is blob opacity from 0 to 1.
- The "brand" palette mixes --chart-1, --chart-2, --chart-4 and --chart-5 with the background, so it follows the theme.
## FAQ
**Does it change between light and dark?**
Yes. Every preset has a pale light-mode set and a deeper dark-mode set. Custom colors are used in both.
**Can I make it static?**
There is no off switch for the motion besides reduced motion; for a still look use a very low speed, or a CSS gradient background.
## Related
- dot-grid: https://goodui.dev/components/backgrounds/dot-grid
- particles: https://goodui.dev/components/backgrounds/particles
- gradient-text: https://goodui.dev/components/text/gradient-text
- liquid-glass-button: https://goodui.dev/components/buttons/liquid-glass-button
Last updated: 2026-11-02
---
# Particles
> Canvas particles drift gently, link up with fine lines and move out of the pointer's way; density scales with the area.
Particles background for React and shadcn: connected dots that react to the cursor
Page: https://goodui.dev/components/backgrounds/particles
## Install
```bash
npx shadcn@latest add @goodui/particles
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/particles.json`. The file lands in `components/goodui/particles.tsx`.
## When to use
- Hero sections of AI, data or network products, where linked dots suggest connections.
- Login and sign-up screens that need gentle motion behind a centered form.
- Interactive landing sections where the background should move out of the cursor's way.
## When not to use
- A still texture: use dot-grid.
- Soft color without detail: use aurora, which is CSS-only.
- Behind dense app UI or long text: moving dots and lines compete with content. Use dot-grid with a fade.
## Pick by what matters
| Need | Use |
| --- | --- |
| Constellation with lines | defaults (connect, repel) |
| Floating dust, no lines | connect={false} density={2} |
| Background only, no pointer reaction | repel={false} |
| Brand colored | color="var(--primary)" |
| Lighter on low-end devices | density={0.8} maxParticles={120} or connect={false} |
## Usage
```tsx
import type { ReactNode } from "react"
import { Particles } from "@/components/goodui/particles"
export function LoginBackdrop({ children }: { children: ReactNode }) {
return (
{children}
)
}
```
### Dust only, no lines, no pointer reaction
```tsx
```
## API
### Particles
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| density | `number` | `1.4` | Particles per 10,000 px² of area. |
| maxParticles | `number` | `220` | Hard cap on the particle count. |
| size | `[number, number]` | `[0.6, 1.8]` | Radius range in px. |
| speed | `number` | `10` | Drift speed in px per second. |
| connect | `boolean` | `true` | Draw lines between nearby particles. |
| linkDistance | `number` | `110` | Max distance for a connecting line, in px. |
| repel | `boolean` | `true` | Push particles away from the pointer (listens on the parent element). |
| repelRadius | `number` | `110` | Distance in px within which the pointer pushes particles away. |
Also accepts every
attribute.
## Accessibility
- The canvas wrapper is aria-hidden and has pointer-events: none, so content above it stays clickable.
- Reduced motion shows one still frame.
## Motion
- Each particle drifts in its own direction at 40% to 100% of `speed` px per second (10) and wraps around the edges.
- With connect, particles closer than `linkDistance` px (110) are joined by thin lines that fade with distance.
- With repel, the pointer pushes particles within `repelRadius` px (110) away; they ease back to their own drift.
- It pauses while off-screen and while the tab is hidden. Reduced motion: one still frame is drawn.
## Performance
- Canvas 2D, devicePixelRatio capped at 2. The count is density per 10,000 px² of area, capped by maxParticles (220).
- Lines are checked between every pair of particles, so cost grows quickly with the count; lower density or turn off connect on large areas.
- The color is resolved once and re-read when the theme class on or the system color scheme changes.
## Notes for AI
- Place it inside a relative parent; it fills it with absolute inset-0. Give the content after it position: relative.
- repel listens to pointer moves on the parent element, since the canvas itself ignores the pointer.
- size is a [min, max] radius tuple in px; speed is px per second.
- color accepts any CSS color or variable and is resolved for canvas automatically.
## FAQ
**Is this tsParticles or particles.js?**
No. It is a small hand-written Canvas 2D component with no dependencies.
**Does it follow dark mode?**
Yes. With the default var(--foreground), or any theme variable, it re-reads the color when the theme changes.
## Related
- dot-grid: https://goodui.dev/components/backgrounds/dot-grid
- flickering-grid: https://goodui.dev/components/backgrounds/flickering-grid
- aurora: https://goodui.dev/components/backgrounds/aurora
- spotlight-card: https://goodui.dev/components/cards/spotlight-card
Last updated: 2026-11-02
---
# Marquee
> An endless horizontal or vertical marquee with faded edges that pauses on hover, shown with customer logos, testimonials and a live bookings ticker.
Infinite marquee for React and shadcn: logo scroller and testimonial ticker
Page: https://goodui.dev/components/backgrounds/marquee
## Install
```bash
npx shadcn@latest add @goodui/marquee
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/marquee.json`. The file lands in `components/goodui/marquee.tsx`.
## When to use
- A "trusted by" row of customer logos under a hero.
- Testimonial walls: two or three rows moving in opposite directions, or vertical columns.
- A live ticker of recent bookings or sign-ups ("Maya booked a deep clean · 2 min ago").
## When not to use
- Text that should react to scroll speed: use scroll-velocity.
- Content people must read or click in order (pricing, steps): use a static grid; moving items are hard to compare.
- A carousel with prev/next controls and one item at a time: a marquee has no paging. Use a carousel component.
## Pick by what matters
| Need | Use |
| --- | --- |
| Logo row | defaults (horizontal, faded edges, pauses on hover) |
| Second row moving the other way | reverse |
| Vertical testimonial columns | vertical with a fixed height (className="h-96") |
| Slower | duration={60} |
| Content shorter than the container | repeat={4} |
| Never pause | pauseOnHover={false} |
## Usage
```tsx
import { Marquee } from "@/components/goodui/marquee"
const reviews = [
{ name: "Maya C.", text: "Our no-shows dropped by half in the first month." },
{ name: "Leo P.", text: "Clients book at midnight. I wake up to a full calendar." },
{ name: "Ana S.", text: "Deposits alone paid for the subscription." },
{ name: "Sam O.", text: "Crew schedules finally live in one place." },
]
export function Testimonials() {
return (
{reviews.map((r) => (
{r.text}
{r.name}
))}
)
}
```
### Logo row
```tsx
{logos.map((l) => (
))}
```
### Vertical column
```tsx
{reviews.map((r) => )}
```
## API
### Marquee
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| reverse | `boolean` | `false` | Scroll toward the inline start (right-to-left in LTR, left-to-right under dir="rtl") or bottom-to-top when false; the other way when true. |
| pauseOnHover | `boolean` | `true` | Pause while the pointer is over it or focus is inside it. |
| vertical | `boolean` | `false` | Scroll vertically instead of horizontally; give the marquee a height. |
| duration | `number` | `40` | Seconds for one copy to pass. |
| gap | `string` | `"1rem"` | Space between items and between copies (any CSS length). |
| repeat | `number` | `3` | How many copies to render; raise it if the content is shorter than the container. |
| fade | `boolean` | `true` | Fade the edges with a mask. |
| fadeSize | `string` | `"12%"` | Length of each faded edge (any CSS length). |
| align | `"start" \| "center" \| "end" \| "stretch"` | — | Cross-axis alignment of the items. Defaults to center (horizontal) or stretch (vertical). |
Also accepts every
attribute.
## Keyboard
| Keys | Action |
| --- | --- |
| Tab | Focus moves only through the first copy (the duplicates are inert); with pauseOnHover, focus inside pauses the marquee. |
## Accessibility
- Only the first copy is exposed: the extra copies are aria-hidden and inert, so screen readers read each item once and Tab skips the duplicates.
- pauseOnHover (default) also pauses while focus is inside, so links in the marquee can be read and clicked.
- Reduced motion turns it into a plain scrollable row (or column) with only one copy, so every item stays reachable.
## Motion
- Each copy slides by its own length plus the gap in a seamless linear loop; one copy passes every `duration` s (40).
- Edges fade with a mask (`fadeSize`, 12%).
- It pauses on hover and focus (pauseOnHover), while off-screen and while the tab is hidden.
- Reduced motion: no movement; duplicates are hidden and the row becomes scrollable.
## Performance
- CSS transform animation; no JavaScript runs per frame. An IntersectionObserver pauses it off-screen.
## Notes for AI
- Children are the items, rendered directly inside each track; do not wrap them in an extra flex container.
- gap and fadeSize take CSS lengths as strings ("1rem", "12%"), not numbers. duration is seconds.
- vertical needs a height on the marquee (className="h-96").
- Under dir="rtl" a horizontal marquee moves left-to-right so the copies loop seamlessly; reverse flips it as usual.
- Do not put aria-label on plain divs inside items (it is not allowed there); use role="img" with the label, or sr-only text, e.g. for a star rating.
- Give images alt text: the first copy is the accessible one.
- If you see a gap at the end of the loop, raise repeat; the content must be at least as long as the container.
## FAQ
**Why does the loop show an empty gap?**
The copies together must cover the container. Raise repeat (default 3) for short content or wide screens.
**Can people click links inside it?**
Yes. It pauses on hover and focus by default, and only the first copy is focusable.
## Related
- scroll-velocity: https://goodui.dev/components/text/scroll-velocity
- animated-list: https://goodui.dev/components/lists/animated-list
- card: https://goodui.dev/components/core/card
- progressive-blur: https://goodui.dev/components/cards/progressive-blur
Last updated: 2026-11-02
---
# Counter up
> Counts from 0 (or from) to the value with an ease-out curve when it scrolls into view, with locale formatting, once or replay-on-return, and counts from the current value when it changes.
Count up animation on scroll for React and shadcn: animated stats counter
Page: https://goodui.dev/components/numbers/counter-up
## Install
```bash
npx shadcn@latest add @goodui/counter-up
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/counter-up.json`. The file lands in `components/goodui/counter-up.tsx`.
## When to use
- Stats rows on a landing page ("12,480 bookings this year") that should count when they scroll into view.
- KPI cards on first load, so the numbers draw the eye once.
- A total that changes later (new clients added): it counts from the current value to the new one.
## When not to use
- A live value that changes often and should feel like an odometer: use number-flow.
- Numbers in tables or forms that people read and compare: plain text, motion slows reading there.
- Showing a change (+8.2%) rather than a total: use delta-badge.
## Pick by what matters
| Need | Use |
| --- | --- |
| Count once when the section first appears | defaults (once is true) |
| Replay every time it comes back into view | once={false} |
| Inside a scrolling panel instead of the page | root={panelRef} |
| Start only when fully visible | amount={1} |
| Stagger a row of stats | delay={index * 0.08} |
## Usage
```tsx
import { CounterUp } from "@/components/goodui/counter-up"
const stats = [
{ label: "Bookings this year", value: 12480 },
{ label: "Revenue", value: 384920, format: { style: "currency", currency: "USD", maximumFractionDigits: 0 } as Intl.NumberFormatOptions },
{ label: "Average rating", value: 4.9, suffix: " / 5" },
{ label: "Clients who rebook", value: 68, suffix: "%" },
]
export function Stats() {
return (
{stats.map((s, i) => (
))}
)
}
```
### Count from a baseline and chain an action
```tsx
setShowBadge(true)}
/>
```
### Replay inside a scroll container
```tsx
const panel = React.useRef(null)
…
```
## API
### CounterUp
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value (required) | `number` | — | Target value. Changing it later counts from the current value to the new one. |
| from | `number` | `0` | Start value for the first count. |
| duration | `number` | `1.6` | Seconds the count takes. |
| delay | `number` | `0` | Seconds to wait after the element enters the viewport. |
| ease | `Easing \| Easing[]` | `[0.16, 1, 0.3, 1]` | motion easing for the count; defaults to a strong ease-out [0.16, 1, 0.3, 1]. |
| format | `Intl.NumberFormatOptions` | — | Intl.NumberFormat options, e.g. { style: "currency", currency: "USD" }. |
| locale | `string` | — | Locale passed to Intl.NumberFormat ("de-DE" gives 1.234,5). Defaults to the GooduiLocaleProvider language, else en-US. |
| prefix | `string` | `""` | Text placed before the formatted number. |
| suffix | `string` | `""` | Text placed after the formatted number, e.g. "%" or " / 5". |
| once | `boolean` | `true` | Count only the first time it scrolls into view. With false it resets when it leaves and replays on return. |
| amount | `number` | `0.5` | Share of the element that must be visible before it starts, 0 to 1. |
| root | `React.RefObject` | — | Scroll container to observe instead of the viewport. |
| onComplete | `() => void` | — | Called when a count finishes (also right away when reduced motion skips it). |
Also accepts every attribute.
## Accessibility
- The final formatted value sits in an sr-only span from the start; the counting text is aria-hidden, so screen readers read the real number, not intermediate frames.
- The rest of the props go to the root , so you can add aria-describedby or an id.
## Motion
- Counts with motion's animate() over duration (default 1.6 s) on the ease [0.16, 1, 0.3, 1], starting when amount (default 0.5) of the element is visible, after delay.
- Intermediate frames keep the target's decimal places (4.9 counts as 0.0, 0.1…), unless format sets fraction digits or uses currency.
- With once={false} it resets to from when it leaves the view and counts again on return.
- Reduced motion: the final value is written immediately and onComplete still fires.
## Performance
- Each frame writes textContent directly on the DOM node, so counting never re-renders React. The animation is stopped on unmount or when value changes mid-count.
## Notes for AI
- Pass the target as a number to value; format with format and locale, not by building a string.
- Use suffix for units ("%", " h", " / 5") and format.style "currency" for money.
- For a counter inside an overflow container, pass that container's ref as root, or it will count as soon as the page shows it.
- To replay on a button click, change its key (it remounts and counts from from); there is no replay method.
- It renders an inline-block span: put typography classes on it directly.
## FAQ
**Why does it start at 0 before scrolling into view?**
The server and the first render show from (default 0). The count starts when the element is in view, so a stat below the fold is still at 0 until the user gets there.
**What happens when value changes after the first count?**
It counts from the number currently shown to the new value with the same duration and ease, instead of restarting from from.
## Related
- number-flow: https://goodui.dev/components/numbers/number-flow
- stat-card: https://goodui.dev/components/core/stat-card
- delta-badge: https://goodui.dev/components/numbers/delta-badge
- text-reveal: https://goodui.dev/components/text/text-reveal
Last updated: 2026-11-02
---
# Usage meter
> Labeled usage meter like "SMS credits 7,420 / 10,000" whose fill springs to the new value and shifts to amber and rose near the limit, with optional per-seat segments.
Usage meter for React and shadcn: plan limits bar that turns amber and red near the limit
Page: https://goodui.dev/components/numbers/meter
## Install
```bash
npx shadcn@latest add @goodui/meter
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/meter.json`. The file lands in `components/goodui/meter.tsx`.
## When to use
- Plan limits on a billing or settings page: "SMS credits 7,420 / 10,000", storage in GB, API calls.
- Seat usage, where segments={seats} shows one block per seat.
- Any measured amount within a known range where getting close to the limit matters.
## When not to use
- Progress of a task that finishes (upload, import, onboarding): use progress, which uses role="progressbar".
- A single percentage in a compact space such as a card corner: use circular-progress.
- A trend over time rather than a current level: use sparkline-draw.
## Pick by what matters
| Need | Use |
| --- | --- |
| Plain quota | label, value, max (defaults warn at 75%, danger at 90%) |
| Units like GB | unit=" GB" and format={{ maximumFractionDigits: 1 }} |
| One block per seat or slot | segments={max} |
| A message that changes with the level | hint={({ tone, percent }) => …} |
| Stricter or looser thresholds | warnAt={0.6} dangerAt={0.85} |
## Usage
```tsx
import { Meter, type MeterTone } from "@/components/goodui/meter"
function smsHint({ tone, percent }: { tone: MeterTone; percent: number }) {
if (tone === "danger") return `${percent}% used. Reminders pause at 10,000. Top up to keep them sending.`
if (tone === "warning") return `${percent}% used. At this pace you run out before Dec 1.`
return `${percent}% used. Renews Dec 1.`
}
export function PlanUsage({ sms, storageGb, seats }: { sms: number; storageGb: number; seats: number }) {
return (
)
}
```
### API quota with custom thresholds
```tsx
```
## API
### Meter
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label (required) | `React.ReactNode` | — | Name shown above the bar and used as the meter's accessible name, e.g. "SMS credits". |
| value (required) | `number` | — | Amount used. It rolls with NumberFlow and the fill springs to it; it is clamped between 0 and max for the bar. |
| max (required) | `number` | — | Limit the value is measured against, e.g. 10000 credits. |
| format | `Intl.NumberFormatOptions` | — | Formats value and max, e.g. { maximumFractionDigits: 1 }. |
| locale | `string` | — | Locale passed to Intl.NumberFormat for value and max. Defaults to the GooduiLocaleProvider language, else en-US. |
| unit | `string` | `""` | Text after the numbers, e.g. " GB". |
| warnAt | `number` | `0.75` | Share of max (0 to 1) where the bar turns amber. |
| dangerAt | `number` | `0.9` | Share of max (0 to 1) where the bar turns rose. |
| segments | `number` | — | Split the bar into this many segments, e.g. one per seat. |
| hint | `React.ReactNode \| ((state: { tone: MeterTone; percent: number }) => React.ReactNode)` | — | Small line under the bar. Receives the current tone and percentage. |
| size | `"sm" \| "md" \| "lg"` | `"md"` | Bar height: sm 6px, md 8px, lg 12px. |
| labels | `Partial` | — | Text for built-in controls and screen readers; pass translations here. |
Also accepts every attribute.
## Accessibility
- The bar is role="meter" with aria-valuemin 0, aria-valuemax max and aria-valuenow, and is named by the label through aria-labelledby.
- aria-valuetext reads "7,420 of 10,000, 74%" (with the unit after max). Translate it with labels.valueText(value, max, percent).
- The visible numbers use number-flow, which keeps one sr-only copy of the value; the " / max" text is aria-hidden because the meter already announces it.
- Tone is never the only signal: the numbers and percent are always shown, and hint can say it in words.
## Motion
- The fill is translated (not resized) on a spring (stiffness 120, damping 20, mass 0.9). With segments, each segment starts 25 ms after the previous one.
- The value digits roll through number-flow; the fill and hint colors cross-fade over 500 ms when the tone changes.
- At 0 the fill fades out (150 ms after a 300 ms delay) so no sliver stays at the track edge.
- Reduced motion: the fill and digits jump to the new value with duration 0.
## Notes for AI
- value and max are raw numbers; pass unit and format instead of building "12.6 GB" yourself.
- Tone is computed from value / max against warnAt and dangerAt (shares from 0 to 1, not percents). There is no tone prop to force a color.
- hint can be a function of { tone, percent }; use it for the message instead of reading the tone outside.
- segments should usually equal max for seats (segments={10} with max={10}); with other values each segment covers max / segments.
- Values above max are clamped to a full bar but the number still shows the real value.
- labels={{ valueText: (v, m, p) => `${v} von ${m}, ${p} %` }} translates aria-valuetext; v and m arrive formatted. Under RTL the bar fills from the right.
## FAQ
**Meter or progress?**
Meter for a level within a range that does not "finish" (quota, storage). Progress for work moving toward done. They also use different ARIA roles.
**Can I change the colors?**
Normal uses bg-primary from your theme; warning is amber and danger is rose, with dark-mode shades. Change the FILL and TEXT maps in the installed file to use other colors.
## Related
- progress: https://goodui.dev/components/numbers/progress
- circular-progress: https://goodui.dev/components/numbers/circular-progress
- number-flow: https://goodui.dev/components/numbers/number-flow
- stat-card: https://goodui.dev/components/core/stat-card
Last updated: 2026-11-02
---
# Progress
> Linear progress in four variants: spring-filled determinate, indeterminate double sweep, moving stripes and segmented steps.
Animated progress bar for React and shadcn: determinate, indeterminate, striped and steps
Page: https://goodui.dev/components/numbers/progress
## Install
```bash
npx shadcn@latest add @goodui/progress
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/progress.json`. The file lands in `components/goodui/progress.tsx`.
## When to use
- Uploads, imports and exports where you know how far along the work is.
- Background work of unknown length ("Syncing Google Calendar…"): the indeterminate variant.
- Onboarding or setup flows where a few equal steps should read as segments.
## When not to use
- Quota or plan usage that never finishes: use meter, which warns near the limit.
- A named, clickable list of steps the user moves through: use stepper.
- Short waits under a second or loading a whole panel: use spinners or skeleton-auto.
## Pick by what matters
| Need | Use |
| --- | --- |
| Known progress (upload, import) | variant="determinate" with value and max |
| Unknown duration | variant="indeterminate" (value is ignored) |
| Long job that should look busy while it moves | variant="striped" |
| A handful of equal steps | variant="steps" steps={n} max={n} value={finished} |
| Thin bar in a header or card | size="sm" |
## Usage
```tsx
import { Progress } from "@/components/goodui/progress"
export function ImportStatus({ imported, total }: { imported: number; total: number }) {
return (
)
}
```
### Unknown duration, then done
```tsx
```
### Onboarding steps
```tsx
const steps = ["Business details", "Services and prices", "Opening hours", "Invite your team", "Go live"]
```
## API
### Progress
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value | `number` | `0` | Current value, 0 to max. Ignored by the indeterminate variant. |
| max | `number` | `100` | Value that counts as complete. For steps, set it to the step count so value is the number of finished steps. |
| variant | `"determinate" \| "indeterminate" \| "striped" \| "steps"` | `"determinate"` | determinate: spring fill. indeterminate: looping sweep for unknown duration. striped: fill with moving stripes. steps: one segment per step. |
| steps | `number` | `4` | Number of segments for the steps variant. |
| size | `"sm" \| "md" \| "lg"` | `"md"` | Bar height: sm 4px, md 8px, lg 12px. |
| label | `React.ReactNode` | — | Optional label above the bar. |
| showValue | `boolean` | `false` | Show the percentage (or "step x of n") next to the label. |
| indicatorClassName | `string` | — | Classes for the filled part, e.g. "bg-emerald-500". |
| labels | `Partial
` | — | Text for built-in controls and screen readers; pass translations here. |
Also accepts every attribute.
## Accessibility
- Renders role="progressbar" with aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext ("64%" or "Step 3 of 5").
- With a label, the bar is named by it through aria-labelledby; without one it gets aria-label="Progress". Both built-in strings go through labels (progress, stepOf).
- The indeterminate variant drops the value attributes and sets aria-busy="true".
## Motion
- Determinate, striped and each step segment translate the fill on a spring (stiffness 110, damping 22, mass 0.8); at 0 the fill fades out after 300 ms.
- Indeterminate: two bars sweep across on a 1.6 s CSS loop, the second starting 0.8 s later.
- Striped: the stripes slide on a 0.8 s linear loop while the fill springs to the value.
- Reduced motion: fills jump with duration 0, stripes stop moving, and the indeterminate sweep becomes a static full-width bar at 45% opacity.
## Performance
- Only transform and opacity animate. The indeterminate and striped loops are CSS keyframes, so no JavaScript runs per frame.
## Notes for AI
- value is on the 0 to max scale (default max 100), not 0 to 1.
- For steps, pass steps and max as the same number and value as the count of finished steps; value 2 of 5 reads "Step 3 of 5".
- Color the fill with indicatorClassName ("bg-emerald-500"), not className, which goes on the wrapper.
- showValue is ignored by the indeterminate variant; value is ignored too.
- labels={{ progress: "Fortschritt", stepOf: (s, n) => `Schritt ${s} von ${n}` }} translates the fallback name and the steps text. Under RTL the track is mirrored, so bars fill from the right.
## FAQ
**How do I switch from indeterminate to a real value?**
Change variant from "indeterminate" to "determinate" and pass value; the fill springs in from the start of the track.
**Do the step segments fill one after another?**
Each segment springs on its own share of the value at the same time. When value goes up by one step, only that segment fills, so stepping through looks sequential.
## Related
- meter: https://goodui.dev/components/numbers/meter
- circular-progress: https://goodui.dev/components/numbers/circular-progress
- stepper: https://goodui.dev/components/navigation/stepper
- multi-step-loader: https://goodui.dev/components/feedback/multi-step-loader
Last updated: 2026-11-02
---
# Circular progress
> SVG ring or 270° gauge whose arc springs to the value via stroke-dashoffset, with a rolling percentage or custom label in the center.
Circular progress ring and gauge for React and shadcn with an animated percentage
Page: https://goodui.dev/components/numbers/circular-progress
## Install
```bash
npx shadcn@latest add @goodui/circular-progress
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/circular-progress.json`. The file lands in `components/goodui/circular-progress.tsx`.
## When to use
- A single percentage that deserves a visual: goal reached, utilization, profile completion.
- Revenue or target gauges on a dashboard (variant="gauge") with your own center label.
- Small per-row indicators, such as each staff member's schedule load at 44px.
## When not to use
- Quota with a used / limit readout and warnings: use meter.
- Long task progress in a form or dialog: use progress, a linear bar reads more precisely.
- Comparing several values against each other: use a bar chart; rings are hard to compare.
## Pick by what matters
| Need | Use |
| --- | --- |
| Plain percentage | variant="ring" (default) with value |
| Speedometer-style goal | variant="gauge" with max={goal} |
| Money or other units in the center | label={
} plus your own aria-valuetext |
| Tiny inline ring | size={44} strokeWidth={4} label={null} |
| Color by status | indicatorClassName="text-emerald-500" (the arc uses currentColor) |
## Usage
```tsx
import { CircularProgress } from "@/components/goodui/circular-progress"
import { NumberFlow } from "@/components/goodui/number-flow"
export function RevenueGoal({ revenue, goal }: { revenue: number; goal: number }) {
return (
}
sublabel="of $25k goal"
/>
)
}
```
### Utilization ring with rolling percentage
```tsx
```
### Per-row load indicator
```tsx
= 90 ? "text-rose-500" : member.load >= 75 ? "text-amber-500" : "text-emerald-500"}
aria-label={`${member.name} schedule load`}
/>
```
## API
### CircularProgress
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value (required) | `number` | — | Current value on the 0 to max scale; the arc springs to it. |
| aria-label | `string` | `"Progress"` | Defines a string value that labels the current element. Accessible name for the progress bar. |
| max | `number` | `100` | Value that fills the whole ring or gauge. |
| size | `number` | `120` | Diameter in px. |
| strokeWidth | `number` | — | Ring thickness in px. Defaults to 9% of the size. |
| variant | `"ring" \| "gauge"` | `"ring"` | "ring" is a full circle, "gauge" is a 270° arc open at the bottom. |
| label | `React.ReactNode` | — | Center content. Defaults to the rolling percentage. Pass null to hide it. |
| sublabel | `React.ReactNode` | — | Small line under the center label. |
| indicatorClassName | `string` | — | Classes for the arc; it is drawn with currentColor, e.g. "text-emerald-500". |
| trackClassName | `string` | — | Classes for the track; drawn with currentColor. |
Also accepts every attribute.
## Accessibility
- The root is role="progressbar" with aria-valuemin 0, aria-valuemax max, aria-valuenow and aria-valuetext as a rounded percent.
- aria-label defaults to "Progress"; pass a specific one ("Chair utilization this week").
- The SVG and the center label are aria-hidden. When the label shows something other than a percent, pass your own aria-valuetext; it overrides the default.
## Motion
- On mount the arc springs from empty to the value via stroke-dashoffset (stiffness 70, damping 18, mass 1), and again on every change.
- The default center label is number-flow, so the percentage digits roll with the arc.
- At 0 the arc fades out in 150 ms, because a round cap would still paint a dot.
- Reduced motion: the arc and digits jump to the value with duration 0.
## Notes for AI
- value is on the 0 to max scale (default max 100); for a goal pass max={goal} instead of computing a percent.
- Color the arc with a text-* class in indicatorClassName and the track with trackClassName; the stroke uses currentColor, so bg-* does nothing.
- label={null} hides the center; leaving label undefined shows the rolling percentage.
- size is a number in px, not a Tailwind class; strokeWidth defaults to 9% of size (at least 3).
- With a custom label, also pass aria-valuetext so screen readers hear the same thing.
## FAQ
**Where does the gauge's gap go?**
The gauge is a 270° arc that starts at 7:30 and ends at 4:30, so the open part is at the bottom. The center label is nudged down slightly to sit in the middle of the arc.
**How big is the center text?**
It scales with size: 20% of the diameter for the label (at least 11px) and 9% for the sublabel (at least 10px).
## Related
- progress: https://goodui.dev/components/numbers/progress
- meter: https://goodui.dev/components/numbers/meter
- number-flow: https://goodui.dev/components/numbers/number-flow
- stat-card: https://goodui.dev/components/core/stat-card
Last updated: 2026-11-02
---
# Sparkline draw
> Smooth monotone sparkline that draws itself in, fades in a gradient area, shows point values on hover or arrow keys, and redraws when the data changes.
Animated sparkline for React and shadcn: self-drawing line chart with hover tooltip
Page: https://goodui.dev/components/numbers/sparkline-draw
## Install
```bash
npx shadcn@latest add @goodui/sparkline-draw
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/sparkline-draw.json`. The file lands in `components/goodui/sparkline-draw.tsx`.
## When to use
- A trend under a KPI: revenue, bookings or no-show rate over the last 14 days.
- Dashboards where each card needs a small chart without pulling in a chart library.
- Compact trends where hovering or arrowing to a point should show its date and value.
## When not to use
- A full chart with axes, gridlines or several series: use a charting library (shadcn chart / Recharts).
- A single current value against a limit: use meter or circular-progress.
- Just the change between two periods: use delta-badge.
## Pick by what matters
| Need | Use |
| --- | --- |
| Default dashboard trend | curve="smooth" area tooltip endDot (all defaults) |
| Exact values matter more than looks | curve="linear" |
| Decorative, non-interactive | tooltip={false} (also removes it from the tab order) |
| Negative metric in red | color="#f43f5e" or any CSS color |
| Plain line only | area={false} endDot={false} |
## Usage
```tsx
import { SparklineDraw } from "@/components/goodui/sparkline-draw"
const days = ["Oct 26", "Oct 27", "Oct 28", "Oct 29", "Oct 30", "Oct 31", "Nov 1"]
const revenue = [2240, 2610, 2380, 2950, 3080, 3460, 3720]
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
export function RevenueCard() {
return (
Revenue
{usd.format(3720)}
usd.format(v)}
height={56}
className="mt-3"
aria-label="Revenue, last 7 days"
/>
)
}
```
### No-show rate in rose, straight segments
```tsx
`${v}%`}
/>
```
### Translated screen reader text
```tsx
`Punkt ${n}`,
summary: (first, last, low, high) => `Verlauf von ${first} bis ${last}, Minimum ${low}, Maximum ${high}`,
noData: "Keine Daten",
}}
/>
```
## API
### SparklineDraw
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| data (required) | `number[]` | — | Values in order, oldest first. The line scales between their min and max; changed values redraw the line. |
| color | `string` | `"var(--chart-1)"` | Any CSS color. Defaults to the theme's first chart color. |
| pointLabels | `string[]` | — | One label per point, shown in the tooltip (e.g. dates). |
| height | `number` | `48` | Height in px. The width follows the container. |
| strokeWidth | `number` | `2` | Line thickness in px; the end dot and padding grow with it. |
| area | `boolean` | `true` | Gradient fill under the line. |
| curve | `"smooth" \| "linear"` | `"smooth"` | smooth: monotone curve that never overshoots the data. linear: straight segments. |
| tooltip | `boolean` | `true` | Show the hovered point's value. Arrow keys move it when focused. |
| endDot | `boolean` | `true` | Pulsing dot on the latest point. |
| duration | `number` | `0.9` | Seconds the line takes to draw. |
| formatValue | `(value: number, index: number) => string` | — | Formats values for the tooltip and screen readers. index is -1 for the low and high in the summary. |
| labels | `Partial` | — | Text for built-in controls and screen readers; pass translations here. |
Also accepts every attribute.
## Keyboard
| Keys | Action |
| --- | --- |
| Tab | Focuses the sparkline (only when tooltip is on) and shows the latest point. |
| ArrowLeft / ArrowRight | Moves the highlighted point one step. |
| Home / End | Jumps to the first or last point. |
| Escape | Hides the highlight and tooltip. |
## Accessibility
- The root is role="group" with aria-roledescription="sparkline" and an aria-label summary built from the data ("Trend from $2,240 to $3,720, low …, high …"); your aria-label replaces it.
- The highlighted point is announced through a polite live region as "
: ", falling back to "Point n".
- All built-in text goes through labels (point, summary, noData) for translation; per-point names come from pointLabels.
- The SVG is aria-hidden; the focus ring is the focus-visible ring token.
## Motion
- The line draws in with pathLength over duration (default 0.9 s) on ease [0.65, 0, 0.35, 1]. The gradient area fades in over 0.6 s starting at 45% of that, and the end dot appears at 90%.
- The end dot sends out a ring every 2 s (scale to 2.6 while fading).
- The hover crosshair and point follow the pointer on a stiff spring (stiffness 600, damping 40).
- New values remount the paths so the line draws again; a container resize only reshapes it.
- Reduced motion: the line, area and dot appear at once, the ring is hidden and the crosshair snaps.
## Performance
- One ResizeObserver per sparkline keeps the SVG at the container width; there are no timers, and the ring is a CSS animation.
## Notes for AI
- Per-point names (dates) go in pointLabels, not labels; labels is only for translating built-in screen reader text.
- pointLabels must be the same length and order as data.
- Pass an aria-label that names the metric and period ("Revenue, last 14 days"); the auto summary does not know what the numbers are.
- Width follows the parent; set height in px through the height prop, not a Tailwind h-* class.
- color takes any CSS color or variable (default var(--chart-1)); do not pass a Tailwind class.
## FAQ
**Does the smooth curve invent peaks?**
No. It uses monotone cubic interpolation (Fritsch–Carlson), so the curve never goes above a peak or below a dip in your data.
**What does it render on the server?**
An SVG at a 240px width that is replaced by the measured container width after mount.
**Does it redraw on every re-render?**
No. It redraws only when the values change, because the paths are keyed by the joined data.
## Related
- delta-badge: https://goodui.dev/components/numbers/delta-badge
- stat-card: https://goodui.dev/components/core/stat-card
- counter-up: https://goodui.dev/components/numbers/counter-up
- number-flow: https://goodui.dev/components/numbers/number-flow
Last updated: 2026-11-02
---
# Delta badge
> "+12.4%" change badge whose digits roll, arrow rotates to the new direction and background pulses green or red, with an inverted mode for metrics where down is good.
Percent change badge for React and shadcn: animated +12.4% delta with up and down arrows
Page: https://goodui.dev/components/numbers/delta-badge
## Install
```bash
npx shadcn@latest add @goodui/delta-badge
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/delta-badge.json`. The file lands in `components/goodui/delta-badge.tsx`.
## When to use
- Next to a KPI to show the change from the previous period: "+12.4%" revenue vs last week.
- Metrics where down is good (cancellations, churn, costs) with goodWhen="down", so a drop shows green.
- Comparison tables that refresh, where the change should be noticed when it updates.
## When not to use
- Showing the value itself rather than its change: use number-flow.
- Signalling that a table cell just changed, up or down: use cell-flash.
- Status labels like "Paid" or "Overdue": use badge.
## Pick by what matters
| Need | Use |
| --- | --- |
| Inline in a KPI card or table | variant="soft" (default) |
| Strong emphasis on a hero stat | variant="solid" |
| Quiet, on busy backgrounds | variant="outline" |
| Lower is better (churn, no-shows, cost) | goodWhen="down" |
| Absolute change, not percent | suffix="" or suffix=" pts" |
## Usage
```tsx
import { DeltaBadge } from "@/components/goodui/delta-badge"
const rows = [
{ label: "Revenue", value: "$48,295", delta: 12.4 },
{ label: "Bookings", value: "1,284", delta: 4.1 },
{ label: "Cancellations", value: "38", delta: -3.2, goodWhen: "down" as const },
]
export function WeekOverWeek() {
return (
)
}
```
### Small solid and outline badges
```tsx
```
### Whole-number change without the arrow
```tsx
```
## API
### DeltaBadge
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value (required) | `number` | — | The change, e.g. 12.4 for +12.4%. |
| decimals | `number` | `1` | Decimals shown. |
| suffix | `string` | `"%"` | Text after the number; use "" or " pts" for non-percent changes. |
| goodWhen | `"up" \| "down"` | `"up"` | Which direction is good. Use "down" for churn, no-shows or costs. |
| variant | `"soft" \| "solid" \| "outline"` | `"soft"` | soft: tinted background, solid: filled green or red with white text, outline: colored border and text. |
| size | `"sm" \| "md"` | `"md"` | sm is 20px tall with 11px text, md is 24px with 12px text. |
| hideIcon | `boolean` | `false` | Hide the arrow icon. |
| labels | `Partial` | — | Text for built-in controls and screen readers; pass translations here. |
Also accepts every attribute.
## Accessibility
- An sr-only span reads the change in words, e.g. "Up 12.4%", "Down 3.2%" or "No change, 0.0%"; the arrow, sign and rolling digits are aria-hidden. Translate the words with labels (up, down, flat).
- Green and red are not the only signal: the sign, the arrow direction and the words all say the direction.
- There is no aria-live region; changes are not announced on their own.
## Motion
- Digits roll through number-flow. The arrow rotates on a spring (stiffness 260, damping 18): up at 0°, down at 180°, sideways at 90° for no change.
- When the rounded value changes, a tinted ring grows to 1.35x and fades over 0.6 s, and the number scales 1 → 1.12 → 1 in 0.35 s. Nothing pulses on first render.
- Background and text colors cross-fade over 300 ms when the tone flips.
- Reduced motion: no ring and no scale pulse; the arrow and digits snap to the new state.
## Notes for AI
- value is the change itself in percent units (12.4 means +12.4%), not a ratio (0.124) and not the new total.
- Do not add a + or − yourself; the badge adds the sign and uses the true minus character.
- Color comes from direction and goodWhen; there is no tone prop. Use goodWhen="down" for cancellations, churn, refunds and costs.
- The value is rounded to decimals (default 1) before deciding the direction, so 0.04 shows as flat "0.0%".
- labels={{ up: (a) => `Plus ${a}`, down: (a) => `Minus ${a}`, flat: (a) => `Unverändert, ${a}` }} translates the screen-reader text; a is the formatted amount with suffix.
## FAQ
**Why is my drop shown in green?**
goodWhen="down" is set, so a decrease counts as good. Remove it for metrics where up is good.
**Does it work in a table that re-renders often?**
Yes. It only pulses when the rounded value actually changes, so re-renders with the same value do nothing.
## Related
- number-flow: https://goodui.dev/components/numbers/number-flow
- stat-card: https://goodui.dev/components/core/stat-card
- cell-flash: https://goodui.dev/components/numbers/cell-flash
- badge: https://goodui.dev/components/core/badge
Last updated: 2026-11-02
---
# Cell flash
> Wraps a table value and briefly flashes it green when it rises, red when it falls, with a fading ▲/▼, like a trading terminal.
Flash table cell on value change in React: green up, red down, like a trading terminal
Page: https://goodui.dev/components/numbers/cell-flash
## Install
```bash
npx shadcn@latest add @goodui/cell-flash
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/cell-flash.json`. The file lands in `components/goodui/cell-flash.tsx`.
## When to use
- Live tables (revenue per staff member, order counts, prices) where you want the eye drawn to what just changed.
- Totals that update from a websocket or polling, to show the direction of the change at a glance.
- Status columns (strings) that should blink when they change, without implying up or down.
## When not to use
- A big hero number that should animate its digits: use number-flow.
- Showing the size of a change persistently (+12.4%): use delta-badge.
- Values that change many times a second: the flashes overlap into noise; throttle the updates first or show a sparkline-draw.
## Pick by what matters
| Need | Use |
| --- | --- |
| Numbers that rise and fall | value={n} (direction is computed) |
| Formatted display | value={n} with children={usd.format(n)} |
| Text status that changed | value={status} (flashes neutral sky blue) |
| Direction known from the server | direction="up" \| "down" \| "neutral" |
| Calmer table | indicator={false} or a shorter duration |
## Usage
```tsx
import { CellFlash } from "@/components/goodui/cell-flash"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
type Staff = { name: string; bookings: number; revenue: number }
export function StaffToday({ rows }: { rows: Staff[] }) {
return (
Staff
Bookings
Revenue
{rows.map((r) => (
{r.name}
{usd.format(r.revenue)}
))}
)
}
```
### Status text and a forced direction
```tsx
{order.status}
0 ? "up" : "down"} duration={600}>
{price.formatted}
```
## API
### CellFlash
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value (required) | `number \| string` | — | The value to watch. Numbers flash green when they rise and red when they fall; other values flash neutral. |
| children | `React.ReactNode` | — | Rendered content. Defaults to the value. |
| direction | `"up" \| "down" \| "neutral"` | — | Force a direction instead of comparing with the previous value. |
| duration | `number` | `900` | Flash length in ms. |
| indicator | `boolean` | `true` | Show a small ▲/▼ that fades out with the flash. |
Also accepts every attribute.
## Accessibility
- The flash background and the ▲/▼ arrow are aria-hidden; screen readers only read the content, and changes are not announced.
- Direction is shown by color and by the arrow; turn indicator off only where the color alone is acceptable.
## Motion
- On each change a tinted background fades from full to zero over duration (default 900 ms) on a cubic-bezier(.3,0,.4,1).
- The ▲ or ▼ slides 3px into place (from below for up, from above for down), holds, then fades out within the same duration.
- Nothing flashes on mount or when a re-render passes the same value.
- Reduced motion: the arrow only fades, without sliding; the background fade stays because it is opacity only.
## Performance
- Pure CSS animations keyed by a change counter; no timers or JavaScript per frame. The flash and arrow are absolutely positioned, so they never shift the table layout.
## Notes for AI
- Pass the raw number to value and the formatted text as children; with only a formatted string as value it cannot tell up from down.
- Strings and mixed types flash neutral (sky); only number-to-number changes get green or red.
- The wrapper has -mx-1.5 px-1.5 so the highlight extends past the text without moving it; do not remove that padding in tight cells.
- Keep the row key stable (an id, not the index) so a re-sort does not make every cell flash.
## FAQ
**Can I change the colors?**
They are emerald, rose and sky with dark-mode shades, in the BG and FG maps at the top of the installed file.
**Where does the arrow go?**
Just after the value, absolutely positioned outside the text box, so it never pushes the layout. Leave a little end padding in the cell for it.
## Related
- delta-badge: https://goodui.dev/components/numbers/delta-badge
- number-flow: https://goodui.dev/components/numbers/number-flow
- table-row-presence: https://goodui.dev/components/lists/table-row-presence
- sparkline-draw: https://goodui.dev/components/numbers/sparkline-draw
Last updated: 2026-11-02
---
# Toast stack
> Self-contained toaster with toast() API: cards stack and expand on hover, swipe away with a drag, morph from loading to success or error for promises, and pause their timers on hover.
Stacked toast notifications for React and shadcn: toast() API, swipe to dismiss, promise toasts
Page: https://goodui.dev/components/feedback/toast-stack
## Install
```bash
npx shadcn@latest add @goodui/toast-stack
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/toast-stack.json`. The file lands in `components/goodui/toast-stack.tsx`.
## When to use
- Short confirmations after an action: "Booking confirmed", "Invoice sent".
- Async work that should show loading, then success or error, in the same toast (toast.promise).
- Reversible actions with an Undo button ("Appointment cancelled" + Undo).
- Background events the user did not trigger directly, such as going offline.
## When not to use
- Errors the user must fix before going on (form validation): show them inline with input-shake or an alert.
- Persistent page-level state such as a trial ending: use an alert banner.
- A running list of activity the user can scroll back through: use animated-list.
## Pick by what matters
| Need | Use |
| --- | --- |
| Plain message | toast("Saved") |
| Outcome with an icon | toast.success / .error / .warning / .info |
| Async request | toast.promise(fn, { loading, success, error }) |
| Update an existing toast | toast(title, { id }) with the same id |
| Must stay until closed | duration: Infinity (loading toasts never close on their own) |
| Always show the full list | |
## Usage
```tsx
// app/layout.tsx: mount the toaster once
import { Toaster } from "@/components/goodui/toast-stack"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
// anywhere in a client component
import { toast } from "@/components/goodui/toast-stack"
function cancelAppointment(client: string, time: string) {
toast("Appointment cancelled", {
description: `${client} on ${time}`,
duration: 6000,
action: { label: "Undo", onClick: () => toast.success("Appointment restored") },
})
}
```
### Loading, then success or error
```tsx
toast.promise(() => sendInvoice(1042), {
loading: "Sending invoice #1042…",
description: "Emailing a PDF to the client",
success: (id) => `Invoice #${id} sent`,
error: () => "Invoice #1042 could not be sent. Try again.",
action: { label: "View", onClick: () => router.push("/invoices/1042") },
})
```
### Manual loading toast updated by id
```tsx
const id = toast.loading("Syncing Google Calendar…")
await sync()
toast.success("Calendar synced", { id })
```
### Translated toaster
```tsx
```
## API
### Toaster
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| position | `"top-left" \| "top-center" \| "top-right" \| "bottom-left" \| "bottom-center" \| "bottom-right"` | `"bottom-right"` | Screen corner or edge the stack is pinned to; top positions stack downward, bottom ones upward. |
| visibleToasts | `number` | `3` | Toasts visible in the collapsed stack. |
| duration | `number` | `4000` | Default auto-dismiss time in ms. |
| gap | `number` | `12` | Space between toasts when expanded, in px. |
| expand | `boolean` | `false` | Keep the stack expanded instead of expanding on hover. |
| showTimer | `boolean` | `true` | Thin bar showing the time left. |
| labels | `Partial` | — | Text for built-in controls and screen readers; pass translations here. |
| sound | `boolean \| SoundName` | — | Plays a sound for each new toast by type (success, error, warning, otherwise "notify") when the visitor has sounds on (lib/goodui-sounds); a sound name is used for every type. Off by default. |
Also accepts every attribute.
## Keyboard
| Keys | Action |
| --- | --- |
| Alt+T | Moves focus to the newest toast (listened for on window while the Toaster is mounted). |
| Tab | Moves between toasts and their action and close buttons; focus inside the stack expands it and pauses timers. |
| Escape | Closes the focused toast, unless dismissible is false. |
## Accessibility
- The toaster is a with aria-live="polite" and aria-relevant="additions text", labelled "Notifications (Alt+T)", so new toasts and text changes (a promise resolving) are announced.
- Each toast is a focusable list item; the close button has an aria-label from labels.close.
- Timers pause while the pointer is over the stack, while focus is inside it and while the tab is hidden, so nothing disappears while someone is reading it.
- Built-in strings (notifications, close) go through the labels prop for translation.
## Motion
- New toasts enter 24px from the edge at 0.96 scale; position, scale and height move on a spring (stiffness 380, damping 34, mass 0.9). Exit is a 180 ms fade to 0.94 scale.
- Collapsed, older toasts scale down 5% per level and peek out 10px each; hover or focus fans them out to their real heights.
- Dragging sideways fades the card; past 80px or a 600 px/s flick it flies off in 200 ms and closes. Otherwise it snaps back.
- When the type changes (loading to success), the icon pops in on a spring from 0.4 scale and -45° rotation. A thin bar at the bottom shrinks over the duration.
- Reduced motion: toasts fade without moving, the timer bar is hidden, the icon swap is a fade and the loading spinner slows to 2.4 s per turn.
## Performance
- The store is a tiny module-level list read with useSyncExternalStore; only the Toaster re-renders when toasts change. Timers are one setTimeout per visible toast, with the remaining time kept across pauses.
## Notes for AI
- Mount exactly once (root layout); call toast() from anywhere on the client. The store is global, so a second Toaster would show the same toasts twice.
- toast() returns the id; toast.promise() returns the promise, not an id. Pass options.id to toast.promise if you need to dismiss it.
- Pass the promise as a function (() => save()) or a promise; success and error can be functions of the result or error.
- Only one action per toast; clicking it runs onClick and closes the toast. There is no cancel button option.
- Do not use toasts for validation errors or anything that needs a decision; they auto-close after 4 s by default.
## FAQ
**Is it compatible with Sonner?**
The call shapes are similar (toast(), toast.success, toast.promise, toast.dismiss) but it is a separate, self-contained store. Replace the import and keep one Toaster.
**What happens if the user closes a loading toast before the promise settles?**
It stays closed: the success or error update only applies if the toast is still in the list.
**How do I keep a toast open?**
Pass duration: Infinity, or use type loading. Updating a toast by id restarts its timer.
## Related
- alert: https://goodui.dev/components/core/alert
- animated-list: https://goodui.dev/components/lists/animated-list
- notification-badge: https://goodui.dev/components/feedback/notification-badge
- success-check: https://goodui.dev/components/feedback/success-check
Last updated: 2026-11-02
---
# Notification badge
> Count badge for icon buttons that springs in, bumps when the count rises, rolls its digits, caps at 99+ and offers a pulse ring or dot variant.
Animated notification badge for React and shadcn: unread count on an icon button
Page: https://goodui.dev/components/feedback/notification-badge
## Install
```bash
npx shadcn@latest add @goodui/notification-badge
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/notification-badge.json`. The file lands in `components/goodui/notification-badge.tsx`.
## When to use
- An unread or pending count on an icon button: booking requests on a bell, emails on an inbox icon.
- A simple "something new" dot on a messages icon (dot).
- Counts that rise while the user watches, where the bump draws the eye without a toast.
## When not to use
- A standalone status or count label in text: use badge.
- Telling the user what happened, not just how many: use toast-stack.
- Counts on mobile tab bar items: bottom-nav and dock have their own badges.
## Pick by what matters
| Need | Use |
| --- | --- |
| Exact count | count={n} (caps at 99+ by default) |
| Just "there is something" | dot |
| Urgent, keep pinging | pulse |
| Calm, on-brand count | tone="primary" |
| Show 0 instead of hiding | showZero |
## Usage
```tsx
import { Bell } from "lucide-react"
import { NotificationBadge } from "@/components/goodui/notification-badge"
export function BookingRequestsButton({ count, onOpen }: { count: number; onOpen: () => void }) {
return (
)
}
```
### Dot for new messages
```tsx
```
### Large inbox with a custom cap
```tsx
```
## API
### NotificationBadge
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children (required) | `React.ReactNode` | — | The element the badge sits on, usually an icon button. |
| count (required) | `number` | — | Number shown in the badge. At 0 the badge hides (unless showZero); a higher count bumps it. |
| max | `number` | `99` | Above this the badge reads "99+". |
| dot | `boolean` | `false` | A plain dot instead of a number. |
| pulse | `boolean` | `false` | A ring that keeps pinging outward while the count is above zero. |
| showZero | `boolean` | `false` | Keep the badge visible at 0. |
| tone | `"red" \| "primary" \| "emerald" \| "amber"` | `"red"` | Badge color: red (rose) for alerts, primary for your theme color, emerald or amber. |
| placement | `"top-right" \| "top-left" \| "bottom-right"` | `"top-right"` | Badge corner. Left and right are mirrored under RTL (right means the inline end). |
| label | `string` | `"unread"` | Words for screen readers after the count. |
| labels | `Partial` | — | Text for built-in controls and screen readers; pass translations here. |
| badgeClassName | `string` | — | Classes for the badge itself; className goes on the wrapper around children. |
Also accepts every attribute.
## Accessibility
- The visible badge is aria-hidden; an sr-only polite live region reads "3 booking requests", or "More than 99 …" above max, and is empty at 0.
- label is the word after the count (default "unread"); pass a translated phrase. Translate the capped wording with labels.moreThan.
- Give the wrapped button its own aria-label; the badge does not label it.
## Motion
- The badge springs in from scale 0 when the count leaves 0 and springs out when it returns (stiffness 520, damping 22).
- When the count goes up from a non-zero value, it swells to 1.3x in 100 ms and settles back on a bouncy spring (stiffness 520, damping 13). Digits roll through number-flow.
- pulse adds a ring that pings outward every 1.6 s while the count is above 0.
- Reduced motion: the badge fades in and out, there is no bump or ping, and the digits snap.
## Notes for AI
- Wrap the icon button as children; the badge positions itself on its corner. Do not position it yourself.
- count is a number; the component formats "99+" from max. Do not pass "99+" strings.
- label is the screen reader noun ("booking requests"), not the button's name.
- dot still announces the count to screen readers, so pass a real count (1) rather than true.
- placement is mirrored under RTL: top-right sits on the inline end (the left corner in RTL).
- labels={{ moreThan: (max) => `Mehr als ${max}` }} translates the screen-reader text for capped counts.
## FAQ
**Why doesn't it bump when the count goes from 0 to 1?**
Going from 0 the badge springs in instead; the bump only plays when an already visible count rises.
**Does it animate on first render?**
No. AnimatePresence has initial={false}, so a badge that is there on mount just appears.
## Related
- badge: https://goodui.dev/components/core/badge
- toast-stack: https://goodui.dev/components/feedback/toast-stack
- bottom-nav: https://goodui.dev/components/navigation/bottom-nav
- dock: https://goodui.dev/components/navigation/dock
Last updated: 2026-11-02
---
# Skeleton auto
> SkeletonSwap traces its children's text lines, controls and avatars ( or data-skeleton elements) into shimmer blocks with one shared sweep, then cross-fades to the real content; includes line, circle and block primitives.
Auto skeleton loader for React and shadcn: shimmer placeholders traced from your real layout
Page: https://goodui.dev/components/feedback/skeleton-auto
## Install
```bash
npx shadcn@latest add @goodui/skeleton-auto
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/skeleton-auto.json`. The file lands in `components/goodui/skeleton-auto.tsx`.
## When to use
- Cards, lists and profiles that load data, where you want a skeleton shaped exactly like the result without drawing one by hand.
- Layouts that change often: the skeleton follows the markup, so it never drifts out of date.
- Hand-made skeletons too: Skeleton, SkeletonLine and SkeletonCircle share the same shimmer.
## When not to use
- Short waits in a button or input: use spinners.
- Operations with known progress: use progress.
- Multi-stage tasks the user should follow step by step: use multi-step-loader.
## Pick by what matters
| Need | Use |
| --- | --- |
| Skeleton shaped like the real content | with placeholder data as children |
| Full control over the shapes | fallback={…} built from SkeletonLine / SkeletonCircle / Skeleton |
| Avatar or chart as one solid block | data-skeleton on that element |
| Leave a part out (badges, decorations) | data-skeleton-ignore on that element |
| Standalone placeholder block | |
## Usage
```tsx
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SkeletonSwap } from "@/components/goodui/skeleton-auto"
const placeholder = { name: "Maya Chen", email: "maya.chen@hey.com", notes: "Prefers Priya for color. Patch test before every appointment." }
export function ClientCard({ client, loading }: { client?: typeof placeholder; loading: boolean }) {
const c = client ?? placeholder
return (
{c.name.split(" ").map((n) => n[0]).join("")}
{c.notes}
)
}
```
### Hand-made fallback
```tsx
}
>
```
### Primitives on their own
```tsx
```
## API
### Skeleton
Also accepts every attribute.
### SkeletonLine
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| width | `number \| string` | `"100%"` | CSS width, e.g. "60%" or 120. |
Also accepts every
attribute.
### SkeletonCircle
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| size | `number` | `40` | Diameter in px. |
Also accepts every
attribute.
### SkeletonSwap
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| loading (required) | `boolean` | — | While true, the children are hidden and the skeleton shows; set it to false to cross-fade to the content. |
| children (required) | `React.ReactNode` | — | The real content. While loading, render it with placeholder data: it stays invisible and its layout is traced into skeleton blocks. Mark parts with data-skeleton (one solid block) or data-skeleton-ignore (skip). |
| fallback | `React.ReactNode` | — | Hand-made skeleton instead of the traced one. |
| loadingLabel | `string` | `"Loading…"` | Announced to screen readers while loading. |
Also accepts every
attribute.
## Accessibility
- While loading, the root has aria-busy and an sr-only role="status" says loadingLabel (default "Loading…"; pass a translation).
- The placeholder content is inert and aria-hidden while loading, so nobody can focus or read the fake data; the skeleton layer is aria-hidden too.
## Motion
- One highlight band sweeps across every block every 1.7 s; each block offsets it by its own x position, so it reads as a single sweep over the whole layout.
- Switching loading cross-fades the skeleton and the content over 350 ms (ease [0.4, 0, 0.2, 1]).
- Reduced motion: the sweep is hidden, blocks stay static and the cross-fade shortens to 150 ms.
## Performance
- Tracing walks the content's DOM once per change: one block per wrapped text line (via Range rects) and one per image, svg, video, canvas, input, textarea, select, button, picture, iframe, progress, meter or data-skeleton element.
- It re-traces on resize (ResizeObserver) and DOM mutations (MutationObserver), batched to one animation frame, and only while loading is true.
## Notes for AI
- Render the real component with realistic placeholder data while loading; empty strings produce no text lines and therefore no skeleton.
- Mark avatars, charts and thumbnails with data-skeleton so they become one block; a text-only avatar fallback would otherwise trace as a short text line.
- className goes on the root (it keeps your padding and border); the skeleton is positioned against its padding box.
- With fallback, nothing is traced; the fallback sits in the same grid cell as the content.
- Elements with display: none or visibility: hidden are skipped automatically.
## FAQ
**Does it work on the server?**
Blocks are measured in the browser in a layout effect, so the server HTML has the invisible content (keeping the right size) and the blocks appear before the first paint after hydration.
**Why are my text bars shorter than the line?**
Each bar is 62% of the line height (at least 6px) and centered on it, like real glyphs, and is clipped to its parent's box.
## Related
- spinners: https://goodui.dev/components/feedback/spinners
- progress: https://goodui.dev/components/numbers/progress
- multi-step-loader: https://goodui.dev/components/feedback/multi-step-loader
- card: https://goodui.dev/components/core/card
Last updated: 2026-11-02
---
# Spinners
> Six CSS-only loaders (ring, dots, bars, pulse, orbit, typing) sized in px or presets and painted with currentColor.
CSS loading spinners for React and shadcn: ring, dots, bars, pulse, orbit and typing
Page: https://goodui.dev/components/feedback/spinners
## Install
```bash
npx shadcn@latest add @goodui/spinners
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/spinners.json`. The file lands in `components/goodui/spinners.tsx`.
## When to use
- Inside a button or input while a request runs ("Saving…").
- Short waits where the layout of the result is unknown or small.
- A chat or assistant reply in progress: the typing variant.
## When not to use
- Loading a card, list or page whose shape you know: use skeleton-auto, which avoids layout shift.
- Work with known progress (uploads, imports): use progress.
- Multi-stage operations the user should follow: use multi-step-loader.
## Pick by what matters
| Need | Use |
| --- | --- |
| Default in buttons and fields | variant="ring" size="sm" |
| Softer, friendly loading | variant="dots" or "pulse" |
| Audio or data processing feel | variant="bars" |
| Someone is typing / AI is replying | variant="typing" |
| Larger standalone loader | variant="orbit" size="xl" |
## Usage
```tsx
import { Spinner } from "@/components/goodui/spinners"
import { Button } from "@/components/ui/button"
export function SaveButton({ saving }: { saving: boolean }) {
return (
{saving && }
{saving ? "Saving…" : "Save changes"}
)
}
```
### Assistant typing indicator
```tsx
```
### Colored with text classes
```tsx
```
## API
### Spinner
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| variant | `"ring" \| "dots" \| "bars" \| "pulse" \| "orbit" \| "typing"` | `"ring"` | Loader style: ring, dots, bars, pulse, orbit or typing. |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| number` | `"md"` | Preset or a size in px. |
| label | `string \| null` | `"Loading"` | Read by screen readers. Pass null when a parent already announces the busy state. |
Also accepts every
attribute.
### SpinnerRing
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | `string \| null` | — | Read by screen readers. Pass null when a parent already announces the busy state. |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| number` | — | Preset or a size in px. |
Also accepts every attribute.
### SpinnerDots
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | `string \| null` | — | Read by screen readers. Pass null when a parent already announces the busy state. |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| number` | — | Preset or a size in px. |
Also accepts every attribute.
### SpinnerBars
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | `string \| null` | — | Read by screen readers. Pass null when a parent already announces the busy state. |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| number` | — | Preset or a size in px. |
Also accepts every attribute.
### SpinnerPulse
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | `string \| null` | — | Read by screen readers. Pass null when a parent already announces the busy state. |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| number` | — | Preset or a size in px. |
Also accepts every attribute.
### SpinnerOrbit
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | `string \| null` | — | Read by screen readers. Pass null when a parent already announces the busy state. |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| number` | — | Preset or a size in px. |
Also accepts every attribute.
### SpinnerTyping
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | `string \| null` | — | Read by screen readers. Pass null when a parent already announces the busy state. |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| number` | — | Preset or a size in px. |
Also accepts every attribute.
## Accessibility
- By default the root is role="status" with an sr-only "Loading" text, so screen readers announce it.
- label replaces that text (translate it here); label={null} drops the role and hides the spinner from assistive tech, for when a parent already says it is busy.
## Motion
- ring turns every 0.75 s; dots scale and fade over 1.1 s, staggered 0.15 s; bars stretch over 0.9 s, staggered 0.12 s; pulse sends two rings 0.7 s apart on a 1.4 s loop; orbit's outer dot circles in 1.2 s and the inner one reverses in 0.8 s; typing dots hop over 1 s.
- Reduced motion: nothing rotates, scales or hops. Every variant becomes a slow 1.6 s opacity fade, so it still reads as busy.
## Performance
- Pure CSS keyframes on transform and opacity; no JavaScript, no timers. The stylesheet is shared by every spinner on the page.
## Notes for AI
- Size with the size prop (xs 12, sm 16, md 24, lg 32, xl 48, or a number in px); the parts are sized in em from it, so Tailwind size-* classes alone will not scale the inside.
- Color with a text-* class or by inheriting currentColor; there is no color prop.
- Inside a button that already says "Saving…", pass label={null} so it is not announced twice.
- SpinnerRing, SpinnerDots, SpinnerBars, SpinnerPulse, SpinnerOrbit and SpinnerTyping are shortcuts for Spinner with that variant.
## FAQ
**Why is the spinner still moving with reduced motion on?**
It switches to a gentle opacity fade instead of stopping, so people can still tell something is loading.
**Does it line up with text?**
Yes. Each spinner is an inline-flex box of 1em (its font-size is the size in px) with vertical-align: middle, so it sits on the text line next to a label.
## Related
- skeleton-auto: https://goodui.dev/components/feedback/skeleton-auto
- progress: https://goodui.dev/components/numbers/progress
- multi-step-loader: https://goodui.dev/components/feedback/multi-step-loader
- button: https://goodui.dev/components/core/button
Last updated: 2026-11-02
---
# Multi-step loader
> Card or overlay that ticks through steps like "Reserving slot…" with spinners, drawn checkmarks and a filling rail, ending on an animated success state.
Multi-step loader for React and shadcn: step-by-step progress with checkmarks and success state
Page: https://goodui.dev/components/feedback/multi-step-loader
## Install
```bash
npx shadcn@latest add @goodui/multi-step-loader
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/multi-step-loader.json`. The file lands in `components/goodui/multi-step-loader.tsx`.
## When to use
- A request that runs several stages the user should see: check availability, reserve, charge deposit, send confirmation.
- Imports and migrations ("Reading clients.csv", "Merging 18 duplicates") shown as a blocking overlay until done.
- Turning a long wait into visible progress, ending on a clear success state with a next action.
## When not to use
- A single short request: use spinners in the button, or a toast-stack promise toast.
- Steps the user fills in and navigates between: use stepper.
- Progress measured in percent or bytes: use progress.
## Pick by what matters
| Need | Use |
| --- | --- |
| Inline in a page or panel | variant="card" (default) |
| Block the page until done | variant="overlay" with open and onOpenChange |
| Driven by real work | step={index} (controlled); steps.length means done |
| Demo or optimistic timing | running with stepDuration={ms} or an array per step |
| Next action at the end | successAction={ } |
## Usage
```tsx
import { MultiStepLoader } from "@/components/goodui/multi-step-loader"
import { Button } from "@/components/ui/button"
const steps = [
{ label: "Checking availability…", description: "Thu, Nov 6 · 2:30 PM with Priya" },
{ label: "Reserving your slot…", description: "Held for you for 10 minutes" },
{ label: "Charging the $40 deposit…", description: "Visa ending 4242" },
{ label: "Sending confirmation…", description: "Email and SMS to Maya Chen" },
]
export function BookingProgress({ step, onViewBooking }: { step: number; onViewBooking: () => void }) {
return (
View booking
}
/>
)
}
```
### Blocking import overlay
```tsx
setOpen(false)}>Done}
/>
```
### Timed steps with a pause
```tsx
track("booking_done")} />
```
## API
### MultiStepLoader
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| steps (required) | `MultiStep[]` | — | Steps in order: a string, or { label, description } where the description shows under the active step. |
| step | `number` | — | Controlled active step (0-based). steps.length means everything is done. |
| running | `boolean` | — | Auto-advance while true (uncontrolled only). |
| stepDuration | `number \| number[]` | — | Ms per step when auto-advancing. A number per step is allowed. |
| onStepChange | `(step: number) => void` | — | Called with the new step index when auto-advancing (uncontrolled mode only). |
| onComplete | `() => void` | — | Called once every step is done. |
| title | `React.ReactNode` | — | Heading while the steps run. |
| successTitle | `React.ReactNode` | — | Heading on the final success state, next to an animated check. |
| successDescription | `React.ReactNode` | — | Line under successTitle. |
| successAction | `React.ReactNode` | — | Rendered under the success message, e.g. a "View booking" button. |
| variant | `"card" \| "overlay"` | `"card"` | "overlay" covers the page with a blurred backdrop and a centered card. |
| open | `boolean` | `true` | Overlay only. |
| onOpenChange | `(open: boolean) => void` | — | Overlay only. Esc and the backdrop close it once all steps are done. |
| labels | `Partial` | — | Text for built-in controls and screen readers; pass translations here. |
Also accepts every attribute.
## Keyboard
| Keys | Action |
| --- | --- |
| Escape | Overlay only: closes it through onOpenChange(false), but only after every step is done. |
| Tab / Shift+Tab | Overlay only: cycles through focusable elements inside the dialog (e.g. successAction); focus cannot leave it. |
## Accessibility
- An sr-only role="status" polite region announces the current step label (when it is a string) and then successTitle, or "Done" when successTitle is not a string.
- The overlay is role="dialog" with aria-modal, labelled by the title; it takes focus on open, keeps Tab and Shift+Tab inside, and gives focus back to the previously focused element on close.
- The "Step x of n" line and the fallback announcements go through labels (stepOf, done) for translation.
## Motion
- The active step shows a spinner turning every 0.8 s. A finished step pops into a green circle (spring 520 / 24 from 0.4 scale) and its check draws in 280 ms.
- The line to the next step fills top-down in 350 ms after a 100 ms delay; the header rail fills on a spring (stiffness 300, damping 30).
- The active step's description opens to its height in 220 ms. At the end the header slides to a success-check and the successAction row opens after 200 ms.
- Overlay: the backdrop fades in 200 ms and the card springs from 0.95 scale (stiffness 420, damping 32).
- Reduced motion: icons, lines, descriptions and the overlay change with fades or instantly; the spinner keeps turning, slower (2.4 s).
## Notes for AI
- Pick one mode: controlled (step, you advance it as work finishes) or timed (running + stepDuration). onStepChange only fires in timed mode.
- step is 0-based and step === steps.length means done; that is when onComplete fires and the success state shows.
- The overlay cannot be dismissed while running; Esc and backdrop clicks only close it after the last step. Give successAction a close button.
- To restart a timed run, change its key; there is no reset method.
- Keep step labels short and present-tense with an ellipsis ("Reserving your slot…"); put details in description.
- labels={{ stepOf: (s, n) => `Schritt ${s} von ${n}`, done: "Fertig" }} translates the built-in text; title and successTitle are props.
## FAQ
**How do I tie it to a real API call?**
Keep the step index in state, pass it as step, and increment it as each stage of your request resolves. Set it to steps.length when the last one finishes.
**Can a step fail?**
There is no error state. On failure, close the overlay or replace the card and show the error with an alert or a toast.
## Related
- success-check: https://goodui.dev/components/feedback/success-check
- progress: https://goodui.dev/components/numbers/progress
- stepper: https://goodui.dev/components/navigation/stepper
- spinners: https://goodui.dev/components/feedback/spinners
Last updated: 2026-11-02
---
# Confetti
> Hand-written canvas 2D confetti with flipping paper physics, fired over the page from an element or inside a contained canvas via ref, and a no-op under reduced motion.
Confetti for React and shadcn without canvas-confetti: burst from a button or inside a card
Page: https://goodui.dev/components/feedback/confetti
## Install
```bash
npx shadcn@latest add @goodui/confetti
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/confetti.json`. The file lands in `components/goodui/confetti.tsx`.
## When to use
- A real milestone: first booking received, plan upgraded, onboarding finished, goal reached.
- Payment success screens, fired once from the confirm button or from inside the receipt card.
- Playful moments in consumer apps where a burst from the clicked element feels rewarding.
## When not to use
- Routine saves and confirmations: use a toast-stack success toast; confetti on every action quickly gets annoying.
- The success state itself: pair confetti with success-check so the result is clear even with reduced motion.
- Continuous decorative motion: use particles.
## Pick by what matters
| Need | Use |
| --- | --- |
| Burst over the whole page from a button | fireConfetti({ element: e.currentTarget }) |
| Burst at a pointer position | fireConfetti({ origin: { x: e.clientX, y: e.clientY } }) |
| Confetti clipped to one card |
in a relative overflow-hidden parent, then ref.current.fire() |
| Side cannons | two fire calls with angle 60 and 120 from the bottom corners |
| Soft, brand-colored | colors={[…]} shapes={["circle"]} startVelocity 22, gravity 0.6 |
## Usage
```tsx
import { PartyPopper } from "lucide-react"
import { fireConfetti } from "@/components/goodui/confetti"
import { Button } from "@/components/ui/button"
export function PublishBookingPage({ onPublish }: { onPublish: () => Promise
}) {
return (
{
const button = e.currentTarget
await onPublish()
fireConfetti({ element: button, particleCount: 120, spread: 80 })
}}
>
Publish booking page
)
}
```
### Contained in a payment card, two cannons
```tsx
const confetti = React.useRef(null)
const card = React.useRef(null)
function onPaid() {
const rect = card.current?.getBoundingClientRect()
if (!rect) return
confetti.current?.fire({ origin: { x: rect.left + 8, y: rect.bottom - 8 }, angle: 60, spread: 50, startVelocity: 30, particleCount: 60 })
confetti.current?.fire({ origin: { x: rect.right - 8, y: rect.bottom - 8 }, angle: 120, spread: 50, startVelocity: 30, particleCount: 60 })
}
…
```
### Gentle brand-colored burst
```tsx
fireConfetti({
element: e.currentTarget,
particleCount: 45,
spread: 120,
startVelocity: 22,
gravity: 0.6,
shapes: ["circle"],
colors: ["#a78bfa", "#c4b5fd", "#f0abfc", "#fbcfe8"],
scalar: 0.8,
})
```
## API
### Confetti
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| ref | `React.Ref` | — | Receives { fire(options), clear() } to burst inside this canvas. |
| sound | `boolean \| SoundName` | — | Default `sound` for fire(): "celebrate" (true) or a sound name, when the visitor has sounds on. Off by default. |
Also accepts every attribute.
## Accessibility
- Both canvases are aria-hidden and pointer-events: none, so they never block clicks or reach screen readers.
- Confetti is decoration only: announce the success itself in text (success-check, a toast or a heading).
## Motion
- Each piece launches in a cone around angle (default 90°, straight up; spread 70°) at 50–120% of startVelocity (38), slows by decay (0.91) per frame, falls with gravity, wobbles sideways and flips by scaling one axis with cos(), like paper.
- Pieces live about 220 frames (±20%) and fade out over the last 35% of their life; anything that falls 40px below the canvas is dropped.
- Physics is frame-rate independent (normalised to 60 fps, capped at 3 frames per step).
- Reduced motion: fireConfetti() and ref.fire() do nothing at all.
## Performance
- Canvas 2D drawn by hand, no dependencies. The loop runs only while pieces are alive, pauses while the tab is hidden, and the contained canvas also pauses when scrolled off-screen (IntersectionObserver).
- The page canvas is created on the first fireConfetti() call and removed when the last piece is gone. Device pixel ratio is capped at 2.
## Notes for AI
- Call fireConfetti() in an event handler or effect, never during render.
- origin is in viewport px (clientX / clientY) for both fireConfetti and ref.fire; the contained canvas converts it.
- angle is in degrees where 90 is up, 0 is right and 180 is left; spread is the cone width in degrees.
- The canvas is absolute inset-0: put it in a parent with position relative (and overflow-hidden to clip).
- Do not install or import canvas-confetti; the option names (particleCount, spread, startVelocity, decay, ticks, scalar) are already similar.
## FAQ
**Is the API the same as canvas-confetti?**
Close but not identical: the main options share names, while origin is in viewport px instead of 0–1 fractions, and element bursts from an element's center.
**Why does nothing happen on my machine?**
Your OS probably has reduced motion turned on; confetti is a no-op then. Show the success another way too.
## Related
- success-check: https://goodui.dev/components/feedback/success-check
- toast-stack: https://goodui.dev/components/feedback/toast-stack
- particles: https://goodui.dev/components/backgrounds/particles
- multi-step-loader: https://goodui.dev/components/feedback/multi-step-loader
Last updated: 2026-11-02
---
# Success check
> Check or X in a circle that draws its stroke, fills, then pops with a ray-and-ring burst, for payment and booking result screens.
Animated success checkmark for React and shadcn: drawn check or error X with a burst
Page: https://goodui.dev/components/feedback/success-check
## Install
```bash
npx shadcn@latest add @goodui/success-check
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/success-check.json`. The file lands in `components/goodui/success-check.tsx`.
## When to use
- Result screens: payment received, booking confirmed, form submitted.
- The error counterpart on the same screen (variant="error"), so success and failure share one layout.
- Small inline ticks in a checklist that fills in, using burst={false}, a small size and a staggered delay.
## When not to use
- A checkbox the user toggles: use checkbox-draw.
- Confirming routine actions without changing the screen: use a toast-stack success toast.
- Showing progress through several stages before the result: use multi-step-loader, which ends on this check.
## Pick by what matters
| Need | Use |
| --- | --- |
| Big result icon | size={72} (default) with burst |
| Failure | variant="error" |
| Inline list ticks | size={20} burst={false} delay={i * 0.15} |
| Brand color | className="text-primary" (everything uses currentColor) |
| Replay on retry | playKey={attempt} |
## Usage
```tsx
import { SuccessCheck } from "@/components/goodui/success-check"
import { Button } from "@/components/ui/button"
export function PaymentResult({ ok, attempt, onRetry }: { ok: boolean; attempt: number; onRetry: () => void }) {
return (
{ok ? "Deposit paid" : "Card declined"}
{ok ? "$40 deposit for Thu, Nov 6 · 2:30 PM" : "Visa ending 4242 was declined. Try another card."}
{!ok &&
Try again }
)
}
```
### Checklist ticks that land one after another
```tsx
{["Deposit received", "Slot reserved", "Confirmation sent"].map((item, i) => (
{item}
))}
```
## API
### SuccessCheck
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| variant | `"success" \| "error"` | `"success"` | "success" draws a check, "error" draws an X. |
| size | `number` | `72` | Diameter in px. The burst reaches about 15% beyond it. |
| burst | `boolean` | `true` | Rays and a ring that shoot outward once the mark lands. |
| delay | `number` | `0` | Seconds before the drawing starts. |
| playKey | `React.Key` | — | Change it to play the animation again. |
| label | `string` | — | Accessible name. Defaults to "Success" or "Error". |
Also accepts every attribute.
## Accessibility
- The root is role="img" with aria-label "Success" or "Error"; pass label for a specific or translated name ("Payment received").
- It is not a live region. If the result appears without navigation, put the message in a role="status" element so it is announced.
- Color is not the only signal: success draws a check and error draws an X.
## Motion
- The circle outline draws in 0.5 s, the tinted fill springs in from 0.6 scale at 0.28 s (stiffness 260, damping 18), then the check draws in 0.32 s from 0.42 s (the X draws its two strokes at 0.42 s and 0.58 s).
- At about 0.6 s the icon pops to 1.08x and back in 0.35 s, while a ring expands to 1.4x and fades over 0.7 s and eight rays shoot out over 0.5 s. All times shift by delay.
- Strokes stay transparent until their own draw starts, so round caps never show a dot early.
- Reduced motion: the finished icon is shown at once, with no pop and no burst. The setting is read after mount, so server and client render the same markup.
## Notes for AI
- Replay by changing playKey (or variant); do not remount the parent just to replay it.
- size is a number in px; color comes from className text-* (defaults emerald for success, rose for error).
- Use burst={false} at small sizes; the burst reaches about 15% beyond the circle and can clip in tight rows.
- Pass label when the meaning is more specific than "Success" or "Error", or when your UI is not in English.
## FAQ
**How do I show it after a request finishes?**
Render it when the result arrives (or change playKey); it plays on mount. Add confetti for big moments.
**Can I change the timing?**
delay shifts the whole sequence. The durations are fixed in the component; edit the t() calls in the installed file to change them.
## Related
- multi-step-loader: https://goodui.dev/components/feedback/multi-step-loader
- confetti: https://goodui.dev/components/feedback/confetti
- checkbox-draw: https://goodui.dev/components/inputs/checkbox-draw
- toast-stack: https://goodui.dev/components/feedback/toast-stack
Last updated: 2026-11-02
---
# Button
> One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.
Animated button for React and shadcn: 10 variants, 6 tones, 10 hover effects
Page: https://goodui.dev/components/core/button
## Install
```bash
npx shadcn@latest add @goodui/button
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/button.json`. The file lands in `components/goodui/button.tsx`.
## When to use
- Any action on a page: submit a form, open a dialog, start a checkout.
- When one product needs several button styles that still match: solid for the primary action, soft or outline for secondary, ghost in toolbars.
- Marketing pages that want motion (shine, shimmer, arrow) without a second library.
## When not to use
- Navigating to another page: use link (or render an with the same classes), so the browser treats it as a link.
- Turning a setting on and off: use switch.
- Choosing one option from a few: use tabs or a segmented control.
## Pick by what matters
| Need | Use |
| --- | --- |
| The one main action on the screen | variant="solid" (add shadow="glow" for a hero CTA) |
| Secondary actions next to a primary one | variant="soft" or "outline" |
| Toolbars and dense tables | variant="ghost" size="sm", iconOnly for single icons |
| Destructive actions | tone="danger" with variant="soft"; solid only inside a confirm dialog |
| Playful or retro brands | variant="neo" or "pushable" |
| On photos or colorful backgrounds | variant="glass" |
## Usage
```tsx
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/goodui/button"
export function Hero() {
return (
}>
Start free trial
Book a demo
)
}
```
### Async action with loading
```tsx
const [saving, setSaving] = React.useState(false)
{ setSaving(true); await save(); setSaving(false) }}>
Save changes
```
### Icon-only toolbar button
```tsx
```
## API
### Button
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| variant | `"solid" \| "soft" \| "outline" \| "ghost" \| "link" \| "gradient" \| "glass" \| "neo" \| "pushable" \| "dashed"` | `"solid"` | |
| tone | `"primary" \| "neutral" \| "success" \| "warning" \| "danger" \| "info"` | `"primary"` | |
| shadow | `"none" \| "sm" \| "md" \| "lg" \| "glow" \| "hard" \| "inner"` | `"none"` | |
| animation | `"none" \| "lift" \| "press" \| "shine" \| "shimmer" \| "ripple" \| "pulse" \| "arrow" \| "glow" \| "bounce"` | `"none"` | |
| shape | `"square" \| "rounded" \| "pill"` | `"rounded"` | |
| size | `"xs" \| "sm" \| "md" \| "lg" \| "xl"` | `"md"` | |
| iconOnly | `boolean` | — | Square button for a single icon. |
| fullWidth | `boolean` | — | |
| loading | `boolean` | — | Shows a spinner, keeps the width and disables the button. |
| iconLeft | `React.ReactNode` | — | |
| iconRight | `React.ReactNode` | — | |
| sound | `boolean \| SoundName` | — | Plays "tap" on click when the visitor has sounds on (lib/goodui-sounds); pass a sound name to pick another. Off by default. |
Also accepts every attribute.
## Keyboard
| Keys | Action |
| --- | --- |
| Enter / Space | Activates the button (native ). |
| Tab | Moves focus; a focus ring shows on keyboard focus only. |
## Accessibility
- Renders a native , so roles, focus and form submission work without extra code.
- loading sets disabled and aria-busy and keeps the width, so the layout does not jump.
- iconOnly buttons need an aria-label; the icon itself is decorative.
## Motion
- Hover: lift rises 2px in 180 ms, glow blooms a tone-colored shadow, shine sweeps a highlight once, arrow nudges the icons.
- Press: press scales to 0.95, bounce springs back from 0.92, ripple grows from the pointer.
- Looping: shimmer sweeps every 2.6 s and pulse sends a ring every 2 s; keep looping buttons to one per screen.
- Reduced motion: transitions, sweeps, rings and ripples are off; the loading spinner slows down instead of stopping.
## Performance
- All effects are CSS (transform, opacity, background-position) on the compositor; no JavaScript runs on hover. ripple adds one short-lived span per click.
## Notes for AI
- Layers are independent props: variant, tone, shadow, animation, shape, size. Combine them; do not write custom classes for colors.
- Use tone for meaning (danger, success) instead of hard-coded colors like bg-red-500.
- Pass icons through iconLeft and iconRight, not as children, so spacing and the arrow animation work.
- For links use or the link component; do not wrap Button in .
- Keep looping animations (shimmer, pulse) to one button per screen.
## FAQ
**Does it work with my shadcn theme?**
Yes. Colors come from your theme tokens (--primary, --foreground…) through the tone variables, so it follows your theme and dark mode.
**Can I use it as a link?**
Use the link component for navigation. If you need the button look on a link, copy the classes onto an .
## Related
- link: https://goodui.dev/components/core/link
- badge: https://goodui.dev/components/core/badge
- shimmer-button: https://goodui.dev/components/buttons/shimmer-button
- magnetic-button: https://goodui.dev/components/buttons/magnetic-button
- rainbow-button: https://goodui.dev/components/buttons/rainbow-button
Last updated: 2026-11-02
---
# Badge
> One badge, every layer: solid, soft, outline, status dot, gradient, glass and neo in six tones, with pulse, shine, bounce-in and count-pop animations, icons, removable chips and clickable filters.
Status badge for React and shadcn: 7 variants, 6 tones, pulse dot, removable chips
Page: https://goodui.dev/components/core/badge
## Install
```bash
npx shadcn@latest add @goodui/badge
```
Without the namespace: `npx shadcn@latest add https://goodui.dev/r/badge.json`. The file lands in `components/goodui/badge.tsx`.
## When to use
- Showing a status next to a record: Confirmed, Pending, No-show, Overdue.
- Tags and filters on a list, removable (× button) or clickable (real ) without extra wrappers.
- Small counts and plan labels: 3 unread, 99+, Pro, Beta.
- A live indicator such as "Online" or "Recording" with a pinging dot.
## When not to use
- An unread count pinned to the corner of an icon or avatar: use notification-badge.
- A trend number like +12% next to a KPI: use delta-badge, or the delta built into stat-card.
- The main action on a screen: use button; a clickable badge reads as a filter or tag, not a call to action.
## Pick by what matters
| Need | Use |
| --- | --- |
| Status column in a table | variant="soft" (default) with tone="success" \| "warning" \| "danger" |
| Neutral status where the color should stay quiet | variant="dot" with the tone coloring only the dot |
| Something live or online right now | animation="pulse" (works best with variant="dot") |
| Unread or cart counts that change | animation="count" with a number child and max={99} |
| Plan or feature labels on marketing pages | variant="gradient" or animation="shine" |
| Tags a user can remove | removable with onRemove |
| Filter chips a user can toggle | clickable, switching variant="solid" / "outline" by state |
## Usage
```tsx
import { Check, Clock } from "lucide-react"
import { Badge } from "@/components/goodui/badge"
const bookings = [
{ client: "Maya Chen", time: "09:30", status: "Confirmed" },
{ client: "Leo Park", time: "11:00", status: "Pending" },
{ client: "Ana Ruiz", time: "13:15", status: "No-show" },
] as const
const tone = { Confirmed: "success", Pending: "warning", "No-show": "danger" } as const
export function TodayBookings() {
return (