FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Badge

Badge

pnpm dlx shadcn@latest add …/r/badge.json

An 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>
}
variants.tsx
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

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :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;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    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>;
  4. 4

    Copy the source into your project.

    components/ui/badge.tsx
    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 };
    components/ui/badge-shine.tsx
    "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 };
  5. 5

    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.

Release notesBeta
as-link.tsx
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.

PropTypeDefaultDescription
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?booleanfalseRender the single child (for example a link) with the badge styles instead of a span.

Related components

  • Spinner

    An accessible React spinner for Next.js and Tailwind CSS: one line going round an infinity that fills it once the task is done.

  • Button

    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.

  • 64%
    Progress

    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.