Badge
pnpm dlx shadcn@latest add …/r/badge.jsonAn accessible React badge for Next.js and Tailwind CSS: small status labels in five variants, including one with a light travelling round its edge.
- VerifiedDefault
- DraftSecondary
- FailedDestructive
- v2.4.0Outline
- BetaShine
import { Badge } from "@/components/ui/badge"
export function Status() {
return <Badge variant="secondary">Draft</Badge>
}import { Badge } from "@/components/ui/badge";
import { BadgeCheckIcon } from "lucide-react";
/* Every variant with a label it might carry, named underneath */
export default function BadgeVariants() {
return (
<ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
<li className="flex flex-col items-center gap-2">
<Badge>
<BadgeCheckIcon aria-hidden />
Verified
</Badge>
<span className="text-xs text-muted-foreground">Default</span>
</li>
<li className="flex flex-col items-center gap-2">
<Badge variant="secondary">Draft</Badge>
<span className="text-xs text-muted-foreground">Secondary</span>
</li>
<li className="flex flex-col items-center gap-2">
<Badge variant="destructive">Failed</Badge>
<span className="text-xs text-muted-foreground">Destructive</span>
</li>
<li className="flex flex-col items-center gap-2">
<Badge variant="outline">v2.4.0</Badge>
<span className="text-xs text-muted-foreground">Outline</span>
</li>
<li className="flex flex-col items-center gap-2">
<Badge variant="shine">Beta</Badge>
<span className="text-xs text-muted-foreground">Shine</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
import { BadgeShine } from "@/components/ui/badge-shine"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import type { ComponentProps } from "react"; /* * A small status label (owner's roadmap, 2026-10-16: "Small status labels, including one with a shining edge"). * - shadcn's API: `variant` (default, secondary, destructive, outline) and `asChild`, plus `shine`. * - Shine is the outline badge with two lights travelling round its edge, the neutral twin of the site's Beta * badge (which is Navy, and site-only). The lights are the label's own colour, a 1px line with no blur, so it * never reads as a glow; the inside stays clear, so it sits on any surface. They live in BadgeShine, a small * client part, so Badge itself stays server-safe. With reduced motion they rest on the left and right. * - Flat: no shadows. Rendered as a link (asChild), the background shifts on hover. */ const badgeVariants = cva( [ "inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap", "transition-colors duration-(--duration-fast) ease-out", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "[&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0", ], { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", destructive: "border-transparent bg-destructive text-destructive-foreground outline-destructive [a&]:hover:bg-destructive/90", outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", // The lights are drawn by BadgeShine, positioned over this edge shine: "relative border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, }, defaultVariants: { variant: "default" }, }, ); export type BadgeProps = ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { /** Render the single child instead of a span, for example a link, keeping the badge styles */ asChild?: boolean; }; /** * A small label for a status, a count or a category. It renders a `span`, or with `asChild` its single child * (a link, for example). `variant="shine"` adds two lights travelling round the edge. */ function Badge({ className, variant, asChild = false, children, ...props }: BadgeProps) { const Comp = asChild ? Slot.Root : "span"; return ( <Comp data-slot="badge" data-variant={variant ?? "default"} className={cn(badgeVariants({ variant }), className)} {...props} > {/* With asChild, Slottable marks the child that takes the styles, and the shine goes inside it */} <Slot.Slottable>{children}</Slot.Slottable> {variant === "shine" ? <BadgeShine /> : null} </Comp> ); } export { Badge, badgeVariants };"use client"; import { duration } from "@/lib/motion"; import { motion, useReducedMotion } from "motion/react"; /* * The lights travelling round a shine Badge's edge. The only client part of Badge, so Badge stays server-safe. * - How: a ring the size of the badge masks away everything but its 1px edge (a static mask, painted once). * Inside it, a square holding a conic gradient (two lights on opposite sides, the site Beta badge's 15% * spread) turns. Only a transform moves, so the browser composites it without repainting, and motion hands it * to the Web Animations API, off the main thread. One lap takes 3s, the site badge's pace. * - The lights are `currentColor`, the label's colour, over the badge's own `border` edge: light on dark in dark * mode, ink on light in light mode. A line only, no blur or spread, so it never reads as a glow. * - With reduced motion nothing turns, and the lights rest on the left and right. */ // Everything but a 1px ring is masked away, so the badge stays clear inside. Only the mask's alpha counts. The // prefixed pair is for browsers without the standard property; the standard one comes last, so it wins. const ring = { borderRadius: "inherit", WebkitMask: "linear-gradient(black 0 0) content-box, linear-gradient(black 0 0)", WebkitMaskComposite: "xor", mask: "linear-gradient(black 0 0) content-box exclude, linear-gradient(black 0 0)", } as const; // Two lights, each brightest at a quarter of the way round and gone 15% either side of it const lights = { background: "conic-gradient(transparent 10%, currentColor 25%, transparent 40% 60%, currentColor 75%, transparent 90%)", } as const; function BadgeShine() { const reduce = useReducedMotion() ?? false; return ( <span aria-hidden data-slot="badge-shine" className="pointer-events-none absolute -inset-px overflow-clip p-px" style={ring} > {/* A square half as wide again as the badge, centred, so it covers every corner as it turns */} <motion.span key={reduce ? "rest" : "turn"} className="absolute top-1/2 left-1/2 aspect-square w-3/2 -translate-x-1/2 -translate-y-1/2" style={lights} animate={reduce ? undefined : { transform: ["rotate(0turn)", "rotate(1turn)"] }} transition={{ duration: duration.slower * 6, ease: "linear", repeat: Number.POSITIVE_INFINITY }} /> </span> ); } export { BadgeShine };Update the import paths to match your project.
Examples
As a link
With asChild, the badge styles go onto its child, so a link stays a real link. Its background shifts on hover.
import { Badge } from "@/components/ui/badge";
import { ArrowUpRightIcon } from "lucide-react";
export default function BadgeAsLink() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Badge asChild variant="outline">
<a href="https://github.com/FewTone/ui/releases" target="_blank" rel="noreferrer">
Release notes
<ArrowUpRightIcon aria-hidden />
</a>
</Badge>
<Badge asChild variant="shine">
<a href="/roadmap">Beta</a>
</Badge>
</div>
);
}API reference
Also accepts every native span prop. Exports badgeVariants for styling other elements. Flat, with no shadow; icons inside size themselves.
| Prop | Type | Default | Description |
|---|---|---|---|
variant? | "default" | "secondary" | "destructive" | "outline" | "shine" | "default" | The visual style. Shine is the outline badge with two lights travelling round its edge, one lap every 3 seconds; with reduced motion they rest on the left and right. |
asChild? | boolean | false | Render the single child (for example a link) with the badge styles instead of a span. |
Related components
An accessible React spinner for Next.js and Tailwind CSS: one line going round an infinity that fills it once the task is done.
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.
An accessible React progress bar and ring for Next.js and Tailwind CSS that show how far along a task is, with an indeterminate state.