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. Progress

Progress

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

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.

  • 64%
    Bar
  • 64%
    Ring
  • Indeterminate
  • Indeterminate
import { Progress } from "@/components/ui/progress"

export function Upload({ percent }: { percent: number }) {
  return <Progress value={percent} aria-label="Upload" />
}
overview.tsx
"use client";

import { Button } from "@/components/ui/button";
import { Progress, ProgressRing, ProgressValue } from "@/components/ui/progress";
import { PlayIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";

/** Pretend upload: where it's got to at each step */
const steps = [0, 12, 27, 41, 58, 72, 86, 100];

/*
 * A bar and a ring at the same value, and both while indeterminate, named underneath (each row lines its
 * names up at the bottom). Run steps the value from 0 to 100 every 0.7s, and the fill, the arc and the number
 * rise continuously through the steps rather than in parts.
 */
export default function ProgressOverview() {
  const [value, setValue] = useState(64);
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);

  const stop = () => {
    if (timer.current) clearInterval(timer.current);
    timer.current = null;
  };
  // Leaving the page mid-run stops the steps
  useEffect(
    () => () => {
      if (timer.current) clearInterval(timer.current);
    },
    [],
  );

  const run = () => {
    stop();
    let step = 0;
    setValue(steps[0] ?? 0);
    timer.current = setInterval(() => {
      step += 1;
      const next = steps[step];
      if (next === undefined) stop();
      else setValue(next);
    }, 700);
  };

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end">
        <Button variant="outline" size="sm" onClick={run}>
          <PlayIcon aria-hidden />
          Run
        </Button>
      </div>

      <ul className="mx-auto grid w-full max-w-md flex-1 grid-cols-2 content-center items-end justify-items-center gap-x-10 gap-y-8">
        <li className="flex w-full flex-col items-center gap-2">
          <div className="flex w-full items-center gap-3">
            <Progress value={value} aria-label="Upload" />
            <ProgressValue value={value} className="w-10 justify-end text-xs text-muted-foreground" />
          </div>
          <span className="text-xs text-muted-foreground">Bar</span>
        </li>
        <li className="flex flex-col items-center gap-2">
          <ProgressRing value={value} size="lg" showValue aria-label="Upload" />
          <span className="text-xs text-muted-foreground">Ring</span>
        </li>
        <li className="flex w-full flex-col items-center gap-2">
          <div className="flex h-4 w-full items-center">
            <Progress aria-label="Loading" />
          </div>
          <span className="text-xs text-muted-foreground">Indeterminate</span>
        </li>
        <li className="flex flex-col items-center gap-2">
          <ProgressRing size="lg" aria-label="Loading" />
          <span className="text-xs text-muted-foreground">Indeterminate</span>
        </li>
      </ul>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}

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/progress.tsx
    "use client";
    
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva, type VariantProps } from "class-variance-authority";
    import {
      animate,
      motion,
      type Transition,
      useMotionValue,
      useReducedMotion,
      useTransform,
    } from "motion/react";
    import { Progress as ProgressPrimitive } from "radix-ui";
    import { type ComponentProps, useEffect, useRef } from "react";
    
    /*
     * Progress: how far along a task is, as a bar (Progress) or a ring (ProgressRing), both on Radix Progress
     * (role="progressbar" with the value, and no value while it's indeterminate). Owner's roadmap, 2026-10-16.
     * Give each a name with aria-label or aria-labelledby.
     * Motion (.agents/rules/motion.md):
     * - A new value: the bar's fill slides to it (translateX) and the ring's arc draws to it (pathLength). It rises
     *   continuously, not in parts (owner, 2026-10-21): each change takes as long as the gap since the one before,
     *   at an even pace, so updates that keep coming join into one smooth rise and the fill never stops to wait.
     *   A change after a pause eases in on its own (duration.slow, ease.out). Never a spring: its overshoot would
     *   show more than was done, and past 100 at the end. The first paint shows the value as it is.
     * - Indeterminate (`value` null or left out): a segment sweeps across the bar; an arc turns round the ring.
     *   Both are transform loops, which motion hands to the Web Animations API (as Spinner does), so they keep
     *   moving while the page is busy. With reduced motion they stop moving and pulse instead, like Spinner.
     * - ProgressValue, the percentage, counts through every whole number in step with the fill (12, 13, 14…), on
     *   the same timing, so the two never disagree. A deliberate exception to "never a tween through every
     *   number": here the number is the bar read aloud.
     */
    
    /** Radix's rules: a max above 0 (else 100), a value from 0 to max (clamped here rather than refused) */
    function percentOf(value: number | null | undefined, max: number | undefined) {
      if (value == null || Number.isNaN(value)) return null;
      const top = max !== undefined && max > 0 ? max : 100;
      return (Math.min(Math.max(value, 0), top) / top) * 100;
    }
    
    /** Updates closer together than this join into one continuous rise; further apart, each eases in on its own */
    const STEADY = duration.slower * 2;
    
    /**
     * How a change to `target` moves: as long as the gap since the last change, at an even pace, while the changes
     * keep coming; else a quick ease. Progress, its ring and ProgressValue see the same changes at the same moments,
     * so they keep in step.
     */
    function useFollow(target: number | null, reduce: boolean): Transition {
      const last = useRef<{ target: number | null; at: number; transition: Transition }>({
        target,
        at: Number.NEGATIVE_INFINITY,
        transition: { duration: 0 },
      });
      if (target !== last.current.target) {
        const now = typeof performance === "undefined" ? 0 : performance.now();
        const gap = (now - last.current.at) / 1000;
        const transition: Transition = reduce
          ? { duration: 0 }
          : gap <= STEADY
            ? { duration: gap, ease: "linear" }
            : { duration: duration.slow, ease: ease.out };
        last.current = { target, at: now, transition };
      }
      return last.current.transition;
    }
    
    /** The indeterminate loops: a calm sweep, and Spinner's pulse with reduced motion */
    const sweep = { duration: duration.slower * 3, ease: ease.inOut, repeat: Number.POSITIVE_INFINITY };
    const turn = { duration: duration.slower * 3, ease: "linear", repeat: Number.POSITIVE_INFINITY } as const;
    const pulse = { duration: duration.slower * 3, ease: ease.inOut, repeat: Number.POSITIVE_INFINITY };
    
    export type ProgressProps = ComponentProps<typeof ProgressPrimitive.Root>;
    
    /**
     * A bar, built on Radix Progress. `value` from 0 to `max` (100); leave it out, or pass null, while there's
     * no telling how long the task will take.
     */
    function Progress({ className, value, max, ...props }: ProgressProps) {
      const percent = percentOf(value, max);
      const top = max !== undefined && max > 0 ? max : 100;
      const reduce = useReducedMotion() ?? false;
      const follow = useFollow(percent, reduce);
    
      return (
        <ProgressPrimitive.Root
          data-slot="progress"
          value={percent === null ? null : (percent / 100) * top}
          max={top}
          className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
          {...props}
        >
          {percent === null ? (
            <ProgressPrimitive.Indicator asChild>
              {/* A segment 40% of the track wide, from just off the start to just off the end (250% of itself) */}
              <motion.div
                key={reduce ? "pulse" : "sweep"}
                data-slot="progress-indicator"
                className={cn(
                  "absolute inset-y-0 w-2/5 rounded-full bg-primary",
                  reduce ? "left-1/2 -translate-x-1/2" : "left-0",
                )}
                animate={
                  reduce ? { opacity: [1, 0.4, 1] } : { transform: ["translateX(-100%)", "translateX(250%)"] }
                }
                transition={reduce ? pulse : sweep}
              />
            </ProgressPrimitive.Indicator>
          ) : (
            <ProgressPrimitive.Indicator asChild>
              <motion.div
                data-slot="progress-indicator"
                className="size-full rounded-full bg-primary"
                initial={false}
                animate={{ x: `-${100 - percent}%` }}
                transition={follow}
              />
            </ProgressPrimitive.Indicator>
          )}
        </ProgressPrimitive.Root>
      );
    }
    
    const progressRingVariants = cva("relative inline-flex shrink-0 items-center justify-center text-primary", {
      variants: {
        size: {
          sm: "size-(--size-icon-lg)",
          md: "size-(--size-control-lg)",
          lg: "size-16 text-sm",
        },
      },
      defaultVariants: { size: "md" },
    });
    
    /** Stroke widths on the 24-unit drawing, so the line is about 2.5, 3.3 and 4px at 20, 40 and 64px */
    const strokes = { sm: 3, md: 2, lg: 1.5 } as const;
    
    export type ProgressRingProps = Omit<ComponentProps<typeof ProgressPrimitive.Root>, "children"> &
      VariantProps<typeof progressRingVariants> & {
        /** Shows the percentage in the middle, rolling as it changes (md and lg) */
        showValue?: boolean;
      };
    
    /**
     * A ring, built on Radix Progress: the same `value` and `max` as Progress, drawn as an arc that goes round
     * from the top. It takes the text colour (text-primary unless you set another).
     */
    function ProgressRing({ className, value, max, size, showValue = false, ...props }: ProgressRingProps) {
      const percent = percentOf(value, max);
      const top = max !== undefined && max > 0 ? max : 100;
      const reduce = useReducedMotion() ?? false;
      const ring = size ?? "md";
      const stroke = strokes[ring];
      const radius = 12 - stroke / 2;
      const indeterminate = percent === null;
      const loop = !indeterminate ? "still" : reduce ? "pulse" : "turn";
      const follow = useFollow(percent, reduce);
    
      return (
        <ProgressPrimitive.Root
          data-slot="progress-ring"
          data-size={ring}
          value={percent === null ? null : (percent / 100) * top}
          max={top}
          className={cn(progressRingVariants({ size }), className)}
          {...props}
        >
          <ProgressPrimitive.Indicator asChild>
            {/* The drawing turns inside the still root, so nothing passed to the ring turns with it */}
            <motion.span
              key={loop}
              data-slot="progress-indicator"
              className="absolute inset-0 inline-flex"
              animate={
                loop === "turn"
                  ? { transform: ["rotate(0turn)", "rotate(1turn)"] }
                  : loop === "pulse"
                    ? { opacity: [1, 0.4, 1] }
                    : undefined
              }
              transition={loop === "turn" ? turn : loop === "pulse" ? pulse : undefined}
            >
              <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-full">
                <circle cx="12" cy="12" r={radius} stroke="currentColor" strokeWidth={stroke} opacity="0.2" />
                {/* Starts at the top: the rotation is on a group, since a CSS transform would replace it */}
                <g transform="rotate(-90 12 12)">
                  <motion.circle
                    cx="12"
                    cy="12"
                    r={radius}
                    stroke="currentColor"
                    strokeWidth={stroke}
                    strokeLinecap="round"
                    initial={false}
                    // At 0 the round cap would still show a dot, so the arc fades out with its length
                    animate={{
                      pathLength: indeterminate ? 0.25 : percent / 100,
                      opacity: indeterminate || percent > 0 ? 1 : 0,
                    }}
                    transition={follow}
                  />
                </g>
              </svg>
            </motion.span>
          </ProgressPrimitive.Indicator>
          {showValue && percent !== null && ring !== "sm" ? (
            // Positioned, so it paints above the drawing rather than under it
            <ProgressValue value={percent} className={cn("relative", ring === "md" && "text-xs")} />
          ) : null}
        </ProgressPrimitive.Root>
      );
    }
    
    export type ProgressValueProps = Omit<ComponentProps<"span">, "children"> & {
      /** The same value and max as the Progress it labels */
      value?: number | null;
      max?: number;
    };
    
    /**
     * The percentage as text, for beside a bar or inside a ring. It counts to a new value in step with the fill.
     * Hidden from screen readers: the progressbar already says its value.
     */
    function ProgressValue({ value, max, className, ...props }: ProgressValueProps) {
      const percent = percentOf(value, max);
      const reduce = useReducedMotion() ?? false;
      const follow = useFollow(percent, reduce);
      const count = useMotionValue(percent ?? 0);
      const shown = useTransform(count, (current) => Math.round(current));
    
      useEffect(() => {
        if (percent === null) return;
        const counting = animate(count, percent, follow);
        return () => counting.stop();
      }, [percent, follow, count]);
    
      if (percent === null) return null;
      return (
        <span
          aria-hidden
          data-slot="progress-value"
          className={cn("inline-flex font-medium tabular-nums", className)}
          {...props}
        >
          <motion.span>{shown}</motion.span>
          <span>%</span>
        </span>
      );
    }
    
    export { Progress, ProgressRing, ProgressValue, progressRingVariants };
  5. 5

    Update the import paths to match your project.

Examples

With a label

Name the bar with a visible label (aria-labelledby) and describe it with a hint. ProgressValue shows the percentage beside the label.

Uploading report.pdf45%

1.7 MB of 3.8 MB

with-label.tsx
import { Progress, ProgressValue } from "@/components/ui/progress";
import { useId } from "react";

/*
 * A visible label names the bar (aria-labelledby), and a hint under it says what the number means. The
 * percentage beside the label is for sighted readers; the bar already tells screen readers its value.
 */
export default function ProgressWithLabel() {
  const id = useId();
  const value = 45;

  return (
    <div className="grid w-full max-w-xs gap-2">
      <div className="flex items-center justify-between gap-4 text-sm">
        <span id={`${id}-label`} className="font-medium">
          Uploading report.pdf
        </span>
        <ProgressValue value={value} className="text-muted-foreground" />
      </div>
      <Progress value={value} aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`} className="text-xs text-muted-foreground">
        1.7 MB of 3.8 MB
      </p>
    </div>
  );
}

Ring sizes

Small sits beside text like an icon; medium and large have room for the value.

  • Syncing
    Small
  • 60%
    Medium
  • 90%
    Large
ring-sizes.tsx
import { ProgressRing } from "@/components/ui/progress";

/* The ring's three sizes, named underneath: small sits beside text like an icon; medium and large fit the value */
export default function ProgressRingSizes() {
  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">
        <span className="flex items-center gap-2 text-sm">
          <ProgressRing value={30} size="sm" aria-label="Syncing" />
          Syncing
        </span>
        <span className="text-xs text-muted-foreground">Small</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <ProgressRing value={60} showValue aria-label="Storage used" />
        <span className="text-xs text-muted-foreground">Medium</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <ProgressRing value={90} size="lg" showValue aria-label="Course completed" />
        <span className="text-xs text-muted-foreground">Large</span>
      </li>
    </ul>
  );
}

API reference

Progress

A bar, built on Radix Progress (role="progressbar"). Give it a name with aria-label or aria-labelledby. Also accepts every native div prop.

PropTypeDefaultDescription
value?number | null–How far along, from 0 to max. Leave it out, or pass null, while there's no telling: a segment sweeps across instead.
max?number100The value that means done.
getValueLabel?(value: number, max: number) => string"64%"What screen readers say for the value.

ProgressRing

The same as a ring: an arc that goes round from the top. It takes the text colour (text-primary unless you set another).

PropTypeDefaultDescription
value?number | null–How far along, from 0 to max. Without it, an arc turns round the ring.
max?number100The value that means done.
size?"sm" | "md" | "lg""md"20, 40 or 64px. Small fits beside text like an icon.
showValue?booleanfalseShows the percentage in the middle, counting as it changes. Medium and large only.

ProgressValue

The percentage as text, for beside a bar. It counts to a new value in step with the fill. Hidden from screen readers, since the progressbar already says its value.

PropTypeDefaultDescription
value?number | null–The same value as the Progress it labels.
max?number100The same max.

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.

  • Loading
    Loading Button

    An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.

  • Changes saved
    Toast

    An accessible React toast for Next.js: notifications that slide in, stack like a deck and swipe away, with a toast() function and Radix Toast parts.