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

Breadcrumb

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

An accessible React breadcrumb for Next.js and Tailwind CSS: a trail of links whose levels animate as you move, with custom separators and a collapsed middle.

    1. Home
    2. Docs
    3. Components
    4. Breadcrumb
    Four levels
    1. Home
    2. Components
    3. Breadcrumb
    Collapsed
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import Link from "next/link"

export function Trail() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink asChild>
            <Link href="/">Home</Link>
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbLink asChild>
            <Link href="/components">Components</Link>
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
trails.tsx
"use client";

import {
  Breadcrumb,
  BreadcrumbEllipsis,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { SlashIcon } from "lucide-react";
import { useId, useState } from "react";

type Mark = "chevron" | "slash";

/** The levels the collapsed trail folds into its menu */
const hidden = [
  { label: "Docs", href: "/docs" },
  { label: "Installation", href: "/docs/installation" },
];

/*
 * A four-level trail and a deeper one with its middle collapsed into a menu, named underneath, with the
 * separator in the top-right corner. An empty row at the bottom, as tall as the setting's row, keeps them centred.
 */
export default function BreadcrumbTrails() {
  const id = useId();
  const [mark, setMark] = useState<Mark>("chevron");
  const separator = <BreadcrumbSeparator>{mark === "slash" ? <SlashIcon /> : undefined}</BreadcrumbSeparator>;

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-separator`} className="text-sm text-muted-foreground">
          Separator
        </label>
        <Select value={mark} onValueChange={(value) => setMark(value as Mark)}>
          <SelectTrigger id={`${id}-separator`} size="sm" className="w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="chevron">Chevron</SelectItem>
            <SelectItem value="slash">Slash</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <ul className="flex flex-1 flex-col items-center justify-center gap-8">
        <li className="flex flex-col items-center gap-2">
          <Breadcrumb aria-label="Breadcrumb, four levels">
            <BreadcrumbList>
              <BreadcrumbItem>
                <BreadcrumbLink href="/">Home</BreadcrumbLink>
              </BreadcrumbItem>
              {separator}
              <BreadcrumbItem>
                <BreadcrumbLink href="/docs">Docs</BreadcrumbLink>
              </BreadcrumbItem>
              {separator}
              <BreadcrumbItem>
                <BreadcrumbLink href="/components">Components</BreadcrumbLink>
              </BreadcrumbItem>
              {separator}
              <BreadcrumbItem>
                <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
              </BreadcrumbItem>
            </BreadcrumbList>
          </Breadcrumb>
          <span className="text-xs text-muted-foreground">Four levels</span>
        </li>

        <li className="flex flex-col items-center gap-2">
          <Breadcrumb aria-label="Breadcrumb, collapsed">
            <BreadcrumbList>
              <BreadcrumbItem>
                <BreadcrumbLink href="/">Home</BreadcrumbLink>
              </BreadcrumbItem>
              {separator}
              <BreadcrumbItem>
                <DropdownMenu>
                  <DropdownMenuTrigger
                    aria-label="Show hidden levels"
                    className="cursor-pointer rounded-md outline-ring transition-colors duration-(--duration-fast) ease-out hover:text-foreground focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) data-[state=open]:text-foreground"
                  >
                    <BreadcrumbEllipsis />
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="start">
                    {/* Each hidden level is a real link, rendered as the menu item */}
                    {hidden.map((level) => (
                      <DropdownMenuItem key={level.href} asChild>
                        <a href={level.href}>{level.label}</a>
                      </DropdownMenuItem>
                    ))}
                  </DropdownMenuContent>
                </DropdownMenu>
              </BreadcrumbItem>
              {separator}
              <BreadcrumbItem>
                <BreadcrumbLink href="/components">Components</BreadcrumbLink>
              </BreadcrumbItem>
              {separator}
              <BreadcrumbItem>
                <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
              </BreadcrumbItem>
            </BreadcrumbList>
          </Breadcrumb>
          <span className="text-xs text-muted-foreground">Collapsed</span>
        </li>
      </ul>

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

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react 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/breadcrumb.tsx
    "use client";
    
    import { blur, duration, ease, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
    import {
      AnimatePresence,
      type HTMLMotionProps,
      LayoutGroup,
      motion,
      PresenceContext,
      useReducedMotion,
    } from "motion/react";
    import { Slot } from "radix-ui";
    import { Children, type ComponentProps, type ReactNode, useMemo } from "react";
    
    /*
     * Breadcrumb (owner's roadmap, 2026-10-16: "Where you are in the site, as a trail of links"), with shadcn's parts:
     * Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator and
     * BreadcrumbEllipsis. The WAI-ARIA breadcrumb: a nav named "Breadcrumb" holding an ordered list, with the
     * current page marked aria-current="page".
     * - The trail follows you (owner, 2026-10-19: "see if we can do anything with motion", then "coming from left,
     *   more smoother"): when it changes (your app moves to another page and keeps the breadcrumb), a new level grows
     *   out of the one before it, sliding 8px out from the left through the blur on duration.slow, the separator
     *   first and the name a beat later. A level you leave slides back into the one before it. The rest glide to
     *   their new places (layout="position" on spring.snappy), and the level you were on dims to a link as smoothly
     *   as the new one arrives. Give each level a key when you map over a path; static trails are keyed by position
     *   and never move. Nothing animates on first paint (AnimatePresence initial={false}), and with reduced motion
     *   every change is instant.
     * - Links are muted and brighten on hover while their underline fades in (Button's link: the line's colour
     *   moves from transparent, so nothing shifts). A gliding highlight was tried instead and the owner kept the
     *   underline (2026-10-19). The current page is plain foreground text, its colour set on its level.
     * - Separators are hidden from screen readers (the list already says how many levels there are), and the
     *   chevron flips in right-to-left pages.
     * - BreadcrumbEllipsis stands in for levels that are collapsed; put it in a Dropdown Menu's trigger to list them
     *   (as the collapsed trail in the demo does).
     */
    
    function Breadcrumb({ "aria-label": label = "Breadcrumb", ...props }: ComponentProps<"nav">) {
      return <nav aria-label={label} data-slot="breadcrumb" {...props} />;
    }
    
    /** The ordered list of levels */
    function BreadcrumbList({ className, children, ...props }: ComponentProps<"ol">) {
      // Keyed, so AnimatePresence can tell the levels apart: by your keys when you map over a path, by position
      // otherwise (a static trail never changes, so nothing moves)
      const levels = useMemo(() => Children.toArray(children), [children]);
    
      return (
        <ol
          data-slot="breadcrumb-list"
          className={cn(
            "flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2",
            className,
          )}
          {...props}
        >
          {/* The group re-renders the levels once a leaving one has gone, so the rest glide to their places */}
          <LayoutGroup>
            <AnimatePresence initial={false}>{levels}</AnimatePresence>
          </LayoutGroup>
        </ol>
      );
    }
    
    /**
     * How a level comes, goes and moves. A new level grows out of the one before it: it slides 8px out from the left
     * through the blur, and a level you leave slides back in. `follows` holds a level's name back a beat
     * (duration.instant) behind the separator that leads to it, so the trail draws itself left to right. The rest
     * glide to their places when others change.
     */
    function useLevelMotion(follows: boolean) {
      const reduce = useReducedMotion() ?? false;
      const tucked = { opacity: 0, filter: reduce ? blur.shown : blur.hidden, x: reduce ? 0 : -8 };
      return {
        initial: tucked,
        animate: { opacity: 1, filter: blur.shown, x: 0, transitionEnd: { filter: "none" } },
        exit: { ...tucked, transition: reduce ? { duration: 0 } : { duration: duration.fast, ease: ease.in } },
        transition: reduce
          ? { duration: 0 }
          : {
              duration: duration.slow,
              ease: ease.out,
              delay: follows ? duration.instant : 0,
              layout: spring.snappy,
            },
        layout: "position" as const,
      };
    }
    
    export type BreadcrumbItemProps = Omit<HTMLMotionProps<"li">, "children"> & { children?: ReactNode };
    
    function BreadcrumbItem({ className, children, ...props }: BreadcrumbItemProps) {
      const level = useLevelMotion(true);
      return (
        <motion.li
          data-slot="breadcrumb-item"
          className={cn(
            "inline-flex items-center gap-1.5",
            // The current page's colour lives here, so when the page you were on becomes a link it dims as smoothly
            // as the next level arrives (and brightens the same way going back)
            "transition-colors duration-(--duration-slow) ease-out has-data-[slot=breadcrumb-page]:text-foreground",
            className,
          )}
          {...level}
          {...props}
        >
          {/*
           * The level answers to the trail's AnimatePresence; what's inside it doesn't. Otherwise a level that was
           * there at first would stop anything in it from ever animating in (a menu's highlight, for example).
           */}
          <PresenceContext.Provider value={null}>{children}</PresenceContext.Provider>
        </motion.li>
      );
    }
    
    export type BreadcrumbLinkProps = ComponentProps<"a"> & {
      /** Render the single child (your router's Link) with the link's styles */
      asChild?: boolean;
    };
    
    function BreadcrumbLink({ asChild = false, className, ...props }: BreadcrumbLinkProps) {
      const Comp = asChild ? Slot.Root : "a";
      return (
        <Comp
          data-slot="breadcrumb-link"
          className={cn(
            "rounded-md underline decoration-transparent underline-offset-4",
            "transition-colors duration-(--duration-fast) ease-out hover:text-foreground hover:decoration-current",
            "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
            className,
          )}
          {...props}
        />
      );
    }
    
    /**
     * The page you're on: the last item, as text rather than a link to itself. aria-current tells screen readers
     * it's the current page (shadcn's fake role="link" is left out: it can't be focused or followed). It takes the
     * foreground colour from its BreadcrumbItem, which fades it in and out.
     */
    function BreadcrumbPage({ className, ...props }: ComponentProps<"span">) {
      return <span data-slot="breadcrumb-page" aria-current="page" className={className} {...props} />;
    }
    
    export type BreadcrumbSeparatorProps = Omit<HTMLMotionProps<"li">, "children"> & { children?: ReactNode };
    
    /** The mark between two items; a chevron unless you pass your own (a slash, for example) */
    function BreadcrumbSeparator({ children, className, ...props }: BreadcrumbSeparatorProps) {
      const level = useLevelMotion(false);
      return (
        <motion.li
          data-slot="breadcrumb-separator"
          role="presentation"
          aria-hidden="true"
          className={cn("[&>svg]:size-(--size-icon-sm) [&>svg]:rtl:rotate-180", className)}
          {...level}
          {...props}
        >
          {children ?? <ChevronRightIcon />}
        </motion.li>
      );
    }
    
    /** Collapsed levels. Inside a Dropdown Menu's trigger, name the trigger ("Show hidden levels"). */
    function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span">) {
      return (
        <span
          data-slot="breadcrumb-ellipsis"
          className={cn("flex size-6 items-center justify-center", className)}
          {...props}
        >
          <MoreHorizontalIcon aria-hidden className="size-(--size-icon-md)" />
          <span className="sr-only">More</span>
        </span>
      );
    }
    
    export {
      Breadcrumb,
      BreadcrumbEllipsis,
      BreadcrumbItem,
      BreadcrumbLink,
      BreadcrumbList,
      BreadcrumbPage,
      BreadcrumbSeparator,
    };
  5. 5

    Update the import paths to match your project.

Examples

A trail that follows you

Open a folder and the trail grows a level out of the one before it while the list moves on, row by row; press a level above and it plays backwards. Key each level by its path to get this.

  1. Home
folders.tsx
"use client";

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { blur, duration, ease } from "@/lib/motion";
import { FolderIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
import { Fragment, useEffect, useRef, useState } from "react";

type Folder = { name: string; folders?: Folder[] };

const home: Folder = {
  name: "Home",
  folders: [
    {
      name: "Projects",
      folders: [
        {
          name: "FewTone UI",
          folders: [
            { name: "Components", folders: [{ name: "Breadcrumb" }, { name: "Accordion" }] },
            { name: "Docs" },
          ],
        },
        { name: "Website" },
      ],
    },
    { name: "Photos", folders: [{ name: "2026" }, { name: "Travel" }] },
    { name: "Documents" },
  ],
};

/** A level's key: the path down to it, so the same name in two places is two levels */
const keyOf = (path: Folder[], index: number) =>
  path
    .slice(0, index + 1)
    .map((folder) => folder.name)
    .join("/");

/**
 * The folder list moves the way you went (1 deeper, -1 back up): the old list slides 12px away and the new one
 * comes in from the other side, row by row, both through the blur, the way Navigation Menu's panels move.
 */
function useListMotion() {
  const reduce = useReducedMotion() ?? false;
  const shift = reduce ? 0 : 12;
  const hidden = reduce ? blur.shown : blur.hidden;
  const list: Variants = {
    enter: {},
    // Rows arrive one after another, 0.05s apart (motion.md: items appearing in a list)
    center: { transition: { staggerChildren: reduce ? 0 : 0.05 } },
    exit: (direction: number) => ({
      opacity: 0,
      x: -shift * direction,
      filter: hidden,
      transition: reduce ? { duration: 0 } : { duration: duration.fast, ease: ease.in },
    }),
  };
  const row: Variants = {
    enter: (direction: number) => ({ opacity: 0, x: shift * direction, filter: hidden }),
    center: {
      opacity: 1,
      x: 0,
      filter: blur.shown,
      transitionEnd: { filter: "none" },
      transition: reduce ? { duration: 0 } : { duration: duration.slow, ease: ease.out },
    },
  };
  return { list, row };
}

/*
 * A folder browser. Open a folder and the trail grows a level out of the one before it, while the folder list
 * moves on: the old list slides away to the left and the new one comes in from the right, row by row. Press a
 * level above and it all plays backwards: the levels below slide back in and the new list comes from the left.
 * Each level is keyed by its path, which is what lets the trail animate. The old and new lists overlap as they
 * pass (popLayout), in a box as tall as the longest list, so the preview never changes height. Focus moves to
 * the new list (or, in an empty folder, to the level above), because the control that was pressed is gone.
 */
export default function BreadcrumbFolders() {
  const variants = useListMotion();
  const [view, setView] = useState<{ path: Folder[]; direction: number }>({ path: [home], direction: 1 });
  const { path, direction } = view;
  const here = path[path.length - 1] ?? home;
  const trail = useRef<HTMLElement>(null);
  const moved = useRef(false);

  const go = (next: Folder[]) => {
    moved.current = true;
    setView({ path: next, direction: next.length > path.length ? 1 : -1 });
  };

  return (
    <div className="flex w-full max-w-lg flex-col gap-4">
      <Breadcrumb ref={trail} aria-label="Folder">
        <BreadcrumbList>
          {path.map((folder, index) => (
            <Fragment key={keyOf(path, index)}>
              {index > 0 ? <BreadcrumbSeparator /> : null}
              <BreadcrumbItem>
                {index === path.length - 1 ? (
                  <BreadcrumbPage>{folder.name}</BreadcrumbPage>
                ) : (
                  <BreadcrumbLink asChild>
                    <button
                      type="button"
                      className="cursor-pointer"
                      onClick={() => go(path.slice(0, index + 1))}
                    >
                      {folder.name}
                    </button>
                  </BreadcrumbLink>
                )}
              </BreadcrumbItem>
            </Fragment>
          ))}
        </BreadcrumbList>
      </Breadcrumb>

      {/* As tall as the longest list (three folders), so the preview never changes height */}
      <div className="relative min-h-26">
        <AnimatePresence mode="popLayout" initial={false} custom={direction}>
          <motion.div
            key={keyOf(path, path.length - 1)}
            variants={variants.list}
            custom={direction}
            initial="enter"
            animate="center"
            exit="exit"
          >
            <FolderList
              folders={here.folders ?? []}
              label={here.name}
              row={variants.row}
              direction={direction}
              onOpen={(folder) => go([...path, folder])}
              moved={moved}
              trail={trail}
            />
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  );
}

function FolderList({
  folders,
  label,
  row,
  direction,
  onOpen,
  moved,
  trail,
}: {
  folders: Folder[];
  label: string;
  row: Variants;
  direction: number;
  onOpen: (folder: Folder) => void;
  moved: { current: boolean };
  trail: { current: HTMLElement | null };
}) {
  const list = useRef<HTMLUListElement>(null);

  // Mounted after a move: the pressed control has gone, so focus lands on the first folder here, or in an empty
  // folder on the level above
  useEffect(() => {
    if (!moved.current) return;
    moved.current = false;
    const target =
      list.current?.querySelector("button") ??
      Array.from(trail.current?.querySelectorAll<HTMLElement>("[data-slot=breadcrumb-link]") ?? []).at(-1);
    target?.focus();
  }, [moved, trail]);

  if (folders.length === 0) {
    return (
      <motion.p variants={row} custom={direction} className="px-3 py-2 text-sm text-muted-foreground">
        No folders in {label}.
      </motion.p>
    );
  }

  return (
    <ul ref={list} aria-label={`Folders in ${label}`} className="flex flex-col gap-1">
      {folders.map((folder) => (
        <motion.li key={folder.name} variants={row} custom={direction}>
          <Button variant="ghost" size="sm" className="w-full justify-start" onClick={() => onOpen(folder)}>
            <FolderIcon aria-hidden className="text-muted-foreground" />
            {folder.name}
          </Button>
        </motion.li>
      ))}
    </ul>
  );
}

API reference

Breadcrumb

The nav landmark. Also accepts every native nav prop.

PropTypeDefaultDescription
aria-label?string"Breadcrumb"The landmark's name.

BreadcrumbList

The ordered list of levels. It wraps onto more lines when it runs out of room. Levels that come and go animate in and out, and the rest glide to their new places; give each level a key (its path) when you map over one.

PropTypeDefaultDescription

BreadcrumbItem

One level (li), a motion element: it accepts every native li prop and motion's props.

PropTypeDefaultDescription

BreadcrumbLink

A link to a level above. Also accepts every native a prop.

PropTypeDefaultDescription
asChild?booleanfalseRender the single child, for example Next.js Link, with the link's styles.

BreadcrumbPage

The page you're on: the last item, as text, marked aria-current="page".

PropTypeDefaultDescription

BreadcrumbSeparator

The mark between levels, hidden from screen readers.

PropTypeDefaultDescription
children?ReactNodea chevronYour own mark, such as a slash icon. A chevron flips in right-to-left pages.

BreadcrumbEllipsis

Stands for collapsed levels. Put it inside a DropdownMenuTrigger to list them, and name the trigger (aria-label).

PropTypeDefaultDescription

Related components

    • More pages
    Pagination

    An accessible React pagination for Next.js and Tailwind CSS: page links with ellipses, Previous and Next, and a pill that slides to the current page.

  • Dropdown Menu

    An accessible React dropdown menu for Next.js with a highlight that glides between items, shortcuts, checkbox and radio items and sub-menus, built on Radix.

  • Yes. It follows the WAI-ARIA accordion pattern.

    Yes, server components included.
    Accordion

    An accessible React accordion for Next.js: sections that open and close with a smooth height change, as one card, separate cards or plain rows. Built on Radix.