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

Spinner

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

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

  • Small
  • Medium
  • Large
  • In a button
import { Spinner } from "@/components/ui/spinner"

export function Loading() {
  return <Spinner />
}
sizes.tsx
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";

/* The three sizes and one in a button, named underneath */
export default function SpinnerSizes() {
  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">
        <Spinner size="sm" />
        <span className="text-xs text-muted-foreground">Small</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <Spinner />
        <span className="text-xs text-muted-foreground">Medium</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <Spinner size="lg" />
        <span className="text-xs text-muted-foreground">Large</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        {/* The label already says what's happening, so the spinner is hidden from screen readers */}
        <Button variant="outline" disabled>
          <Spinner aria-hidden />
          Saving…
        </Button>
        <span className="text-xs text-muted-foreground">In a button</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority motion
  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/spinner.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, useMotionValue, useReducedMotion, useTransform } from "motion/react";
    import { type ComponentProps, useEffect } from "react";
    
    /*
     * A small loading indicator (owner's roadmap, 2026-10-16), redesigned as the infinity the owner chose in the Lab
     * (2026-10-22), ported from it exactly: the same path, line, timings and finish. In the text's colour.
     * - One line travels round and round an infinity at an even pace over a faint track.
     * - `done` shows the task finished: the line stops where it is and grows forward until it fills the whole
     *   infinity, the track fading out under it. Back to loading, it shrinks to a line and travels on.
     * - Motion: the line is a dash along the path (its offset and length), so only the stroke moves. With reduced
     *   motion it doesn't travel: it pulses its opacity instead, and a finish fills at once.
     * - role="status", named "Loading" unless you give it another aria-label, and "Done" once finished (doneLabel).
     *   Inside a button that already says what's happening ("Saving…"), hide it with aria-hidden, or the button's
     *   name would include it.
     * - Sizes are the icon sizes (14, 16 and 20px), so it can stand in for an icon anywhere.
     */
    
    /**
     * The infinity as one continuous stroke (a dash restarts at every subpath, so it couldn't run from one loop into
     * the other): from the centre up into the right loop, round it, back through the centre into the left loop,
     * round it and home
     */
    const INFINITY =
      "M12 12C14 9 16 8 18 8A4 4 0 1 1 18 16C16 16 14 15 12 12C10 9 8 8 6 8A4 4 0 1 0 6 16C8 16 10 15 12 12Z";
    /** The line's length, in hundredths of the infinity */
    const LINE = 24;
    /** One second: the old spinner's turn; the line goes round in 1.6 of them */
    const TURN = duration.slower * 2;
    const forever = { repeat: Number.POSITIVE_INFINITY } as const;
    
    const spinnerVariants = cva("inline-flex shrink-0", {
      variants: {
        size: {
          sm: "size-(--size-icon-sm)",
          md: "size-(--size-icon-md)",
          lg: "size-(--size-icon-lg)",
        },
      },
      defaultVariants: { size: "md" },
    });
    
    export type SpinnerProps = ComponentProps<"span"> &
      VariantProps<typeof spinnerVariants> & {
        /** The task is finished: the line fills the whole infinity */
        done?: boolean;
        /** Its name once done, for screen readers */
        doneLabel?: string;
      };
    
    function Spinner({
      className,
      size,
      done = false,
      doneLabel = "Done",
      "aria-label": label = "Loading",
      ...props
    }: SpinnerProps) {
      const reduce = useReducedMotion() ?? false;
      // Where the line starts along the infinity, and how long it is, in hundredths
      const offset = useMotionValue(0);
      const length = useMotionValue(done ? 100 : LINE);
      const dasharray = useTransform(length, (line) => `${line} ${Math.max(0, 100 - line)}`);
    
      useEffect(() => {
        if (done) {
          // It stops where it is and fills forward from there
          const fill = animate(
            length,
            100,
            reduce ? { duration: 0 } : { duration: duration.slower * 1.6, ease: ease.out },
          );
          return () => fill.stop();
        }
        const shrink = animate(
          length,
          LINE,
          reduce ? { duration: 0 } : { duration: duration.slow, ease: ease.out },
        );
        if (reduce) return () => shrink.stop();
        // Round and round at an even pace, on from wherever it is
        const from = offset.get();
        const travel = animate(offset, [from, from - 100], { duration: TURN * 1.6, ease: "linear", ...forever });
        return () => {
          shrink.stop();
          travel.stop();
        };
      }, [done, reduce, offset, length]);
    
      return (
        <span
          role="status"
          aria-label={done ? doneLabel : label}
          data-slot="spinner"
          data-size={size ?? "md"}
          data-done={done || undefined}
          className={cn(spinnerVariants({ size }), className)}
          {...props}
        >
          {/* With reduced motion the drawing pulses while loading; finished, it holds still */}
          <motion.span
            key={reduce && !done ? "pulse" : "still"}
            className="inline-flex size-full"
            animate={reduce && !done ? { opacity: [1, 0.4, 1] } : { opacity: 1 }}
            transition={
              reduce && !done ? { duration: TURN * 1.5, ease: ease.inOut, ...forever } : { duration: 0 }
            }
          >
            <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-full">
              <motion.path
                d={INFINITY}
                stroke="currentColor"
                strokeWidth="2.2"
                strokeLinejoin="round"
                initial={false}
                animate={{ opacity: done ? 0 : 0.15 }}
                transition={reduce ? { duration: 0 } : { duration: duration.slower, ease: ease.out }}
              />
              <motion.path
                data-slot="spinner-line"
                d={INFINITY}
                pathLength={100}
                stroke="currentColor"
                strokeWidth="2.2"
                strokeLinecap="round"
                strokeLinejoin="round"
                style={{ strokeDasharray: dasharray, strokeDashoffset: offset }}
              />
            </svg>
          </motion.span>
        </span>
      );
    }
    
    export { Spinner, spinnerVariants };
  5. 5

    Update the import paths to match your project.

Examples

Finish

Set done when the task is finished: the line stops where it is and grows until it fills the whole infinity. Back to loading, it shrinks and travels on.

Saving…
finish.tsx
"use client";

import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useState } from "react";

/* The finish: done fills the whole infinity. The row is the status, so it's read as its text. */
export default function SpinnerFinish() {
  const [done, setDone] = useState(false);
  return (
    <div className="flex flex-col items-center gap-4">
      <div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
        <Spinner aria-hidden done={done} />
        {done ? "Saved" : "Saving…"}
      </div>
      <Button variant="outline" size="sm" onClick={() => setDone((current) => !current)}>
        {done ? "Restart" : "Finish"}
      </Button>
    </div>
  );
}

With text

Beside text, put role="status" on the row and hide the spinner, so it's read once as the text.

Loading comments…
with-text.tsx
import { Spinner } from "@/components/ui/spinner";

/* Beside text, the row is the status, so it's read once as "Loading comments…" */
export default function SpinnerWithText() {
  return (
    <div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
      <Spinner aria-hidden />
      Loading comments…
    </div>
  );
}

API reference

A status (role="status") drawn in currentColor, so it takes the text's colour. Also accepts every native span prop.

PropTypeDefaultDescription
size?"sm" | "md" | "lg""md"14, 16 or 20px, the icon sizes, so it can stand in for an icon.
aria-label?string"Loading"What's loading, for screen readers. Inside a button or beside text that already says it, pass aria-hidden instead.
done?booleanfalseThe task is finished: the line fills the whole infinity and stops.
doneLabel?string"Done"Its name for screen readers once done.

Related components

  • Loading
    Loading Button

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

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

  • Stablev2.4.0Beta
    Badge

    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.