fluid
uisidebarsource: ok · refreshed
Sidebar
Refined, composable app sidebar with offcanvas collapse, floating and inset variants, a mobile drawer, cookie-persisted state, keyboard shortcut, and fluid-hover menus with traveling selection, hover, and focus indicators. Radix flavor.
npx shadcn add "https://www.fluidfunctionalism.com/r/sidebar.json"free
Where it goes
statssidebardashboard-shellkanban
Interaction
animated-counteraccordiondropdown
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumui-primitives@radix-ui/react-dialog
animationframer-motion
iconslucide-react
Registry dependencies
utilshttps://www.fluidfunctionalism.com/r/springs.jsonhttps://www.fluidfunctionalism.com/r/font-weight.jsonhttps://www.fluidfunctionalism.com/r/shape-context.jsonhttps://www.fluidfunctionalism.com/r/size-context.jsonhttps://www.fluidfunctionalism.com/r/surface-context.jsonhttps://www.fluidfunctionalism.com/r/surface-classes.jsonhttps://www.fluidfunctionalism.com/r/icon-context.jsonhttps://www.fluidfunctionalism.com/r/use-fluid-hover.jsonhttps://www.fluidfunctionalism.com/r/tooltip.jsonhttps://www.fluidfunctionalism.com/r/scroll-area.jsonhttps://www.fluidfunctionalism.com/r/button.jsonhttps://www.fluidfunctionalism.com/r/tokens.jsonhttps://www.fluidfunctionalism.com/r/type-scale.json
Files3 · source cached 10/8/2026, 9:26:22 AM
registry/radix/sidebar.tsx
"use client";
import {
useEffect,
useRef,
useState,
forwardRef,
type ReactNode,
type CSSProperties,
type HTMLAttributes,
} from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { spring, exitFallbackMs } from "@/lib/springs";
import { useSurface, SurfaceProvider } from "@/lib/surface-context";
import { surfaceClasses } from "@/lib/surface-classes";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
useSidebar,
SidebarShell,
type SidebarSide,
type SidebarVariant,
type SidebarCollapsible,
} from "@/components/ui/sidebar-core";
// ─── Mobile sheet ────────────────────────────────────────────────────────────
//
// Built on Radix Dialog: it provides scroll lock, focus trap, focus restore,
// Esc + outside-click dismissal, while leaving the slide animation to
// Motion. Radix has no actionsRef-style deferred unmount, so the
// portal lifetime is managed with local `mounted` state: mount on open, keep
// the portal alive with `forceMount` through the exit tween, and unmount once
// the panel's exit animation completes.
interface SidebarSheetProps {
side: SidebarSide;
open: boolean;
onClose: () => void;
children: ReactNode;
}
function SidebarSheet({ side, open, onClose, children }: SidebarSheetProps) {
const { widthMobile } = useSidebar();
// Reduced motion drops the slide (the movement) but keeps the scrim's
// opacity fade — the state change stays legible without the travel.
const reduceMotion = useReducedMotion() ?? false;
const substrate = useSurface();
const level = Math.min(substrate + 2, 8);
const panelRef = useRef<HTMLDivElement | null>(null);
const [mounted, setMounted] = useState(false);
// Set during the render that opens it, so the portal mounts in the same
// commit as `open` rather than one after.
if (open && !mounted) setMounted(true);
// Fallback release for the deferred unmount: onAnimationComplete on the
// panel is the primary signal, but rAF-driven animation callbacks can stall
// in throttled/background tabs.
useEffect(() => {
if (open) return;
const id = setTimeout(() => setMounted(false), exitFallbackMs(spring.moderate));
return () => clearTimeout(id);
}, [open]);
const offscreen = side === "left" ? "-100%" : "100%";
return (
<DialogPrimitive.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
>
{mounted && (
<DialogPrimitive.Portal forceMount>
{/* Scrim: an always-on bg-black/40 base that stays visible for
system-dark users (`dark:` only matches the explicit .dark
class), boosted to /80 in explicit dark mode. */}
<DialogPrimitive.Overlay asChild forceMount>
<motion.div
className="fixed inset-0 bg-black/40 dark:bg-black/80 z-40"
initial={{ opacity: 0 }}
animate={{ opacity: open ? 1 : 0 }}
transition={open ? { duration: spring.moderate.duration } : spring.moderate.exit}
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content
asChild
forceMount
// The panel takes initial focus itself. Left to Radix, the trap
// lands on the first focusable child — the top nav row — which
// reads as a selected item the moment the drawer opens, and
// Chrome grants :focus-visible to script-driven focus so it shows
// the keyboard ring too.
onOpenAutoFocus={(event) => {
event.preventDefault();
panelRef.current?.focus();
}}
// Radix warns when Content has no Description; an explicit
// undefined clears the rendered attribute, which is what its
// DescriptionWarning checks.
aria-describedby={undefined}
>
<motion.div
ref={panelRef}
tabIndex={-1}
data-sidebar="sidebar"
data-mobile="true"
data-side={side}
className={cn(
"fixed inset-y-0 z-50 flex flex-col overflow-hidden outline-none",
side === "left" ? "left-0" : "right-0",
surfaceClasses(level, 3)
)}
style={{ width: widthMobile }}
initial={{ x: offscreen }}
// spring.moderate: critically damped, so the panel decelerates
// into x: 0 without overshooting and exposing the page behind
// its leading edge.
animate={{ x: open ? 0 : offscreen }}
transition={reduceMotion ? { duration: 0 } : open ? spring.moderate : spring.moderate.exit}
onAnimationComplete={() => {
if (!open) setMounted(false);
}}
>
{/* Radix's TitleWarning checks for a rendered DialogTitle, so
ship a visually hidden one as the accessible name. */}
<DialogPrimitive.Title className="sr-only">Sidebar</DialogPrimitive.Title>
<SurfaceProvider value={level}>{children}</SurfaceProvider>
</motion.div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)}
</DialogPrimitive.Root>
);
}
// ─── Sidebar ─────────────────────────────────────────────────────────────────
export interface SidebarProps
extends Omit<
HTMLAttributes<HTMLDivElement>,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration"
> {
side?: SidebarSide;
variant?: SidebarVariant;
/** `"icon"` collapse is intentionally not supported — offcanvas or none. */
collapsible?: SidebarCollapsible;
/** The `sidebar` variant's inner-edge border. Default true. */
bordered?: boolean;
/** Pin the rail's tooltip open (`true`) or closed (`false`); `undefined`
* leaves it on hover. Dragging always hides it. */
railTooltipOpen?: boolean;
/** Render the built-in resize/collapse rail. Default true. */
rail?: boolean;
}
const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
(
{ side = "left", variant = "sidebar", collapsible = "offcanvas", bordered = true, rail = true, railTooltipOpen, className, style, children, ...props },
ref
) => {
const { isMobile, openMobile, setOpenMobile, width, registerSide } = useSidebar();
// The provider mirrors the side into the default shortcut ("[" / "]")
// and the rail handle.
useEffect(() => registerSide(side), [side, registerSide]);
if (collapsible === "none") {
return (
<div
ref={ref}
data-slot="sidebar"
data-variant={variant}
data-side={side}
className={cn(
"peer sticky top-0 flex h-svh shrink-0 flex-col",
side === "right" && "order-last",
className
)}
style={{ width, ...style } as CSSProperties}
{...props}
>
<div
data-sidebar="sidebar"
className={cn(
"flex h-full w-full min-h-0 flex-col",
bordered &&
variant === "sidebar" &&
(side === "left" ? "border-r border-border" : "border-l border-border")
)}
>
{children}
</div>
</div>
);
}
// The desktop shell stays MOUNTED across the drawer breakpoint — its
// breakpoint classes fade it out (opacity + display, allow-discrete)
// instead of this component unmounting it, which snapped the rail away
// the instant the window shrank. The sheet mounts alongside it below the
// breakpoint; the hidden shell costs nothing visible (display: none).
return (
<>
{isMobile && (
<SidebarSheet side={side} open={openMobile} onClose={() => setOpenMobile(false)}>
{children}
</SidebarSheet>
)}
<SidebarShell ref={ref} side={side} variant={variant} bordered={bordered} rail={rail} railTooltipOpen={railTooltipOpen} className={className} style={style} {...props}>
{children}
</SidebarShell>
</>
);
}
);
Sidebar.displayName = "Sidebar";
// ─── SidebarContent ──────────────────────────────────────────────────────────
export interface SidebarContentProps extends HTMLAttributes<HTMLDivElement> {
viewportClassName?: string;
}
const SidebarContent = forwardRef<HTMLDivElement, SidebarContentProps>(
({ className, viewportClassName, children, ...props }, ref) => {
const { isMobile } = useSidebar();
// Inside the mobile sheet, the sheet's flex column owns layout and this
// region scrolls natively — a nested ScrollArea would double-scroll. The
// boundary hairline still needs a frame to ride: scroll-divider can't sit
// on the scroller itself (its own fade mask would erase the line), so the
// region is wrapped the way ScrollArea wraps its viewport on desktop.
if (isMobile) {
return (
<div className="scroll-divider [--scroll-divider-inset:8px] flex min-h-0 w-full flex-1 flex-col">
<div
ref={ref}
data-sidebar="content"
className={cn("scroll-fade flex min-h-0 w-full flex-1 flex-col overflow-y-auto", className)}
{...props}
>
{children}
</div>
</div>
);
}
// The scroll primitive wraps children in an inline-styled sizer that
// sizes to content — rows would stop shrinking near the min width
// instead of truncating, so the viewport's direct child is forced back
// to a plain shrinkable block.
return (
<ScrollArea className={cn("scroll-divider min-h-0 w-full flex-1", className)} viewportClassName={cn("scroll-fade [&>div]:!block [&>div]:!min-w-0", viewportClassName)}>
<div ref={ref} data-sidebar="content" className="flex w-full min-w-0 flex-col" {...props}>
{children}
</div>
</ScrollArea>
);
}
);
SidebarContent.displayName = "SidebarContent";
export { Sidebar, SidebarContent };
// Re-export the flavor-neutral parts so `sidebar` is a one-stop import.
export {
SidebarProvider,
useSidebar,
SidebarTrigger,
SidebarRail,
SidebarInset,
SidebarInput,
SidebarHeader,
SidebarFooter,
SidebarSeparator,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupAction,
SidebarGroupActions,
SidebarGroupContent,
SIDEBAR_COOKIE_NAME,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_MOBILE,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_KEYBOARD_SHORTCUT_RIGHT,
SIDEBAR_MIN_WIDTH,
SIDEBAR_MAX_WIDTH,
} from "@/components/ui/sidebar-core";
export type {
SidebarContextValue,
SidebarProviderProps,
SidebarTriggerProps,
SidebarRailProps,
SidebarInsetProps,
SidebarInputProps,
SidebarSectionProps,
SidebarGroupLabelProps,
SidebarGroupActionProps,
SidebarSide,
SidebarVariant,
SidebarCollapsible,
} from "@/components/ui/sidebar-core";
export {
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuAction,
SidebarMenuActions,
SidebarMenuBadge,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
sidebarMenuButtonVariants,
} from "@/components/ui/sidebar-menu";
export type {
SidebarMenuProps,
SidebarMenuItemProps,
SidebarMenuButtonProps,
SidebarMenuActionProps,
SidebarMenuBadgeProps,
SidebarMenuSkeletonProps,
SidebarMenuSubProps,
SidebarMenuSubItemProps,
SidebarMenuSubButtonProps,
} from "@/components/ui/sidebar-menu";
registry/default/sidebar-core.tsx
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
useRef,
useId,
forwardRef,
isValidElement,
Children,
type ReactNode,
type ReactElement,
type ElementType,
type CSSProperties,
type HTMLAttributes,
type Ref,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { spring, exitFallbackMs } from "@/lib/springs";
import { fontWeights } from "@/lib/font-weight";
import { useShape } from "@/lib/shape-context";
import { useSize, useSizeVariant, typeClass } from "@/lib/size-context";
import { useIcons } from "@/lib/icon-context";
import { useSurface, SurfaceProvider } from "@/lib/surface-context";
import { surfaceClasses } from "@/lib/surface-classes";
import { Button, type ButtonProps } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";
// SSR-safe layout effect (client components still server-render in Next).
const useIsoLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
// ─── Constants ───────────────────────────────────────────────────────────────
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
/** Bare-key toggle defaults: "[" for a left sidebar, "]" for a right one.
* Bare (no ⌘/Ctrl) so the browser's history shortcuts stay untouched. */
export const SIDEBAR_KEYBOARD_SHORTCUT = "[";
export const SIDEBAR_KEYBOARD_SHORTCUT_RIGHT = "]";
/** Drag-resize clamp for the built-in rail handle (px). */
export const SIDEBAR_MIN_WIDTH = 160;
export const SIDEBAR_MAX_WIDTH = 360;
/** Dragging this far past the minimum width collapses the sidebar instead of
* bottoming out — the same "throw it at the edge to dismiss" affordance
* native apps use. */
export const SIDEBAR_COLLAPSE_SLOP = 56;
// ─── Context ─────────────────────────────────────────────────────────────────
export type SidebarSide = "left" | "right";
export type SidebarVariant = "sidebar" | "floating" | "inset";
export type SidebarCollapsible = "offcanvas" | "none";
export interface SidebarContextValue {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
openMobile: boolean;
setOpenMobile: React.Dispatch<React.SetStateAction<boolean>>;
isMobile: boolean;
toggleSidebar: () => void;
/** Live rail width — the rail's drag-resize updates it. */
width: string;
setWidth: (width: string) => void;
widthMobile: string;
mobileBreakpoint: number;
/** Which edge the rendered Sidebar sits on (registered by <Sidebar/>). */
side: SidebarSide;
/** Internal: <Sidebar/> reports its side so the provider can resolve the
* default shortcut and the rail can mirror. */
registerSide: (side: SidebarSide) => void;
/** The resolved toggle key ("[" / "]" / custom / null when disabled). */
shortcut: string | null;
/** Collapsed-peek mode: reveal the sidebar as a floating overlay from the
* collapsed edge on hover or click, without pinning it open. */
peek: "hover" | "click" | "none";
/** True while the collapsed sidebar is peeking as an overlay. */
isPeeking: boolean;
setIsPeeking: React.Dispatch<React.SetStateAction<boolean>>;
/** Shared hover-intent machinery for the peek: ONE timer serves every
* affordance that can float the rail out (the edge strip, the trigger),
* so crossing between them — or into the peeked card — cancels a pending
* dismissal instead of racing a second timer. */
schedulePeek: () => void;
scheduleDismissPeek: () => void;
cancelPeekTimer: () => void;
/** Internal: true while the rail is being drag-resized (disables the
* width spring so the panel tracks the pointer 1:1). */
isResizing: boolean;
setIsResizing: React.Dispatch<React.SetStateAction<boolean>>;
}
// Mounted-provider registry for the global toggle shortcut. The listener has
// to be global (the key should work without focus in the sidebar), but only
// ONE provider may answer a keypress: the innermost one containing focus, or —
// when focus is outside every provider — the OUTERMOST mounted one (the
// app-shell provider that wraps everything else; mount order can't be used
// because a persistent layout provider mounts once while demos mount later
// on navigation). Same pattern as AskUserQuestions' 1-9 shortcuts.
const mountedProviders: HTMLElement[] = [];
const SidebarContext = createContext<SidebarContextValue | null>(null);
export function useSidebar(): SidebarContextValue {
const ctx = useContext(SidebarContext);
if (!ctx) throw new Error("useSidebar must be used within a SidebarProvider");
return ctx;
}
// Starts undefined so the server and first client render agree (both treat it
// as desktop); the media query corrects it in an effect before interaction.
function useIsMobile(breakpoint: number): boolean {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const onChange = () => setIsMobile(mql.matches);
onChange();
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [breakpoint]);
return !!isMobile;
}
// ─── SidebarProvider ─────────────────────────────────────────────────────────
export interface SidebarProviderProps extends HTMLAttributes<HTMLDivElement> {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Persist the desktop open state to the `sidebar_state` cookie so a server
* layout can read it back into `defaultOpen`. Mobile state never persists. */
persist?: boolean;
/** Bare-key toggle shortcut. Defaults to "[" for a left sidebar and "]"
* for a right one; `null` disables it. */
shortcut?: string | null;
/** Viewport width (px) below which the sidebar renders as a drawer. */
mobileBreakpoint?: number;
/** While collapsed, reveal the sidebar as a floating overlay from the
* edge — on hover (with intent delay) or on click of the edge strip.
* Peeking never pins the sidebar or writes the cookie. @default "none" */
peek?: "hover" | "click" | "none";
width?: string;
widthMobile?: string;
}
const SidebarProvider = forwardRef<HTMLDivElement, SidebarProviderProps>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange,
persist = true,
shortcut: shortcutProp,
mobileBreakpoint = 768,
peek = "none",
width: widthProp = SIDEBAR_WIDTH,
widthMobile = SIDEBAR_WIDTH_MOBILE,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile(mobileBreakpoint);
const [openMobile, setOpenMobile] = useState(false);
const [side, setSide] = useState<SidebarSide>("left");
const wrapperRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
mountedProviders.push(el);
return () => {
const i = mountedProviders.indexOf(el);
if (i !== -1) mountedProviders.splice(i, 1);
};
}, []);
const registerSide = useCallback((next: SidebarSide) => setSide(next), []);
// Live width: the prop is the starting point, the rail's drag-resize
// updates it at runtime. A new prop resets it, during that render.
const [width, setWidth] = useState(widthProp);
const [syncedWidthProp, setSyncedWidthProp] = useState(widthProp);
if (syncedWidthProp !== widthProp) {
setSyncedWidthProp(widthProp);
setWidth(widthProp);
}
const [isResizing, setIsResizing] = useState(false);
// Default shortcut mirrors the sidebar's edge: "[" left, "]" right.
const shortcut =
shortcutProp === undefined
? side === "right"
? SIDEBAR_KEYBOARD_SHORTCUT_RIGHT
: SIDEBAR_KEYBOARD_SHORTCUT
: shortcutProp;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = openProp ?? internalOpen;
const openRef = useRef(open);
useIsoLayoutEffect(() => {
openRef.current = open;
});
const setOpen = useCallback(
(value: boolean | ((prev: boolean) => boolean)) => {
const next = typeof value === "function" ? value(openRef.current) : value;
if (onOpenChange) onOpenChange(next);
else setInternalOpen(next);
if (persist) {
document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}
},
[onOpenChange, persist]
);
const toggleSidebar = useCallback(() => {
if (isMobile) setOpenMobile((prev) => !prev);
else setOpen((prev) => !prev);
}, [isMobile, setOpen]);
// Collapsed-peek overlay state. Pinning the sidebar open (or disabling
// the mode) always dismisses the peek — including a PENDING intent
// timer, or a hover armed just before the pin would fire setIsPeeking on
// an open sidebar (the effect's deps never re-run for the late timer).
// The peek itself is cleared during the render that pins or disables
// it; the timer is cleared after that commit.
const [isPeeking, setIsPeeking] = useState(false);
const peekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
if ((open || peek === "none") && isPeeking) setIsPeeking(false);
useEffect(() => {
if (open || peek === "none") {
if (peekTimerRef.current) clearTimeout(peekTimerRef.current);
peekTimerRef.current = null;
}
}, [open, peek]);
const cancelPeekTimer = useCallback(() => {
if (peekTimerRef.current) clearTimeout(peekTimerRef.current);
peekTimerRef.current = null;
}, []);
const schedulePeek = useCallback(() => {
cancelPeekTimer();
peekTimerRef.current = setTimeout(() => setIsPeeking(true), 150);
}, [cancelPeekTimer]);
const scheduleDismissPeek = useCallback(() => {
cancelPeekTimer();
peekTimerRef.current = setTimeout(() => setIsPeeking(false), 250);
}, [cancelPeekTimer]);
useEffect(() => cancelPeekTimer, [cancelPeekTimer]);
// The bare shortcut key toggles the sidebar app-wide. Bound to the
// provider's lifetime (not a docs-only global), skipped while typing, and
// skipped when a modifier is held so ⌘[ / ⌘] keep their browser meaning.
useEffect(() => {
if (shortcut == null) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== shortcut.toLowerCase()) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
)
return;
// Only one mounted provider answers (see mountedProviders). Providers
// can NEST (an app shell wrapping doc previews), so containment alone
// isn't enough: the innermost provider containing focus wins.
const root = wrapperRef.current;
if (!root) return;
if (root.contains(target)) {
if (
mountedProviders.some(
(el) => el !== root && root.contains(el) && el.contains(target)
)
)
return;
// The focused element itself WRAPS another provider (a docs
// preview frame holding keyboard scope for the demo inside it —
// see click-to-focus): the wrapped provider answers, not this
// ancestor shell.
if (mountedProviders.some((el) => el !== root && target.contains(el)))
return;
} else {
// Focus outside this provider. A focused element that WRAPS
// providers — a docs preview frame holding keyboard scope for the
// demo inside it (see click-to-focus) — scopes the key to what it
// wraps: the outermost wrapped provider answers, and this check
// must come BEFORE the containment guard below (an app-shell
// provider always contains the preview frame, and must not steal
// the key from the demo the frame scopes to). Focus on <body>
// wraps every provider, which degenerates to the original rule:
// the OUTERMOST provider overall answers — mount order is
// unreliable here (a persistent app-shell provider mounts once,
// while doc demos mount later on client-side navigation).
const wrapped = mountedProviders.filter((el) => target.contains(el));
if (wrapped.length > 0) {
const outermost = wrapped.find(
(el) => !wrapped.some((other) => other !== el && other.contains(el))
);
if (outermost !== root) return;
} else {
if (mountedProviders.some((el) => el !== root && el.contains(target)))
return;
const outermost = mountedProviders.find(
(el) =>
!mountedProviders.some((other) => other !== el && other.contains(el))
);
if (outermost !== root) return;
}
}
event.preventDefault();
toggleSidebar();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [shortcut, toggleSidebar]);
const value = useMemo<SidebarContextValue>(
() => ({
state: open ? "expanded" : "collapsed",
open,
setOpen,
openMobile,
setOpenMobile,
isMobile,
toggleSidebar,
width,
setWidth,
widthMobile,
mobileBreakpoint,
side,
registerSide,
shortcut,
peek,
isPeeking,
setIsPeeking,
schedulePeek,
scheduleDismissPeek,
cancelPeekTimer,
isResizing,
setIsResizing,
}),
[
open,
setOpen,
openMobile,
isMobile,
toggleSidebar,
width,
widthMobile,
mobileBreakpoint,
side,
registerSide,
shortcut,
peek,
isPeeking,
schedulePeek,
scheduleDismissPeek,
cancelPeekTimer,
isResizing,
]
);
return (
<SidebarContext.Provider value={value}>
<div
ref={(node) => {
wrapperRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
data-slot="sidebar-wrapper"
className={cn("group/sidebar-wrapper relative flex min-h-svh w-full", className)}
style={
{
"--sidebar-width": width,
"--sidebar-width-mobile": widthMobile,
...style,
} as CSSProperties
}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
);
SidebarProvider.displayName = "SidebarProvider";
// ─── Slot helpers (render / asChild polymorphism) ────────────────────────────
//
// A local slot instead of a primitive-library one so every menu part exists in
// exactly one flavor-neutral copy: Radix's Slot would leak into the Base UI
// flavor, and Base UI's useRender the other way around. Supports both the
// library's `render={<Link/>}` convention and shadcn's `asChild`.
type SlotProps = {
className?: string;
style?: CSSProperties;
children?: ReactNode;
} & Record<string, unknown>;
function assignRef<T>(target: Ref<T> | undefined, node: T | null) {
if (typeof target === "function") target(node);
else if (target) (target as React.MutableRefObject<T | null>).current = node;
}
/** Resolves the element to clone: `render` wins, else `asChild`'s single
* element child. `content` is what should render inside it — for `asChild`
* the child element's own children, otherwise the caller's. */
export function resolveSlotTemplate(
render: ReactElement | undefined,
asChild: boolean | undefined,
children: ReactNode
): { template: ReactElement<SlotProps> | null; content: ReactNode } {
if (render && isValidElement(render)) {
return { template: render as ReactElement<SlotProps>, content: children };
}
if (asChild) {
const only = Children.toArray(children)[0];
if (isValidElement(only)) {
return {
template: only as ReactElement<SlotProps>,
content: (only.props as SlotProps).children,
};
}
}
return { template: null, content: children };
}
/** Renders `children` into the template element (merging class/style/
* handlers, composing refs) or into the default tag when there is no
* template. A component rather than a helper the parts call during their
* render: the forwarded ref it composes arrives as the `elementRef` prop,
* and only lands on the element at commit. */
export function SlotElement({
template,
defaultTag: DefaultTag,
elementRef,
props,
children,
}: {
template: ReactElement<SlotProps> | null;
defaultTag: ElementType;
elementRef?: Ref<HTMLElement>;
props: SlotProps;
children?: ReactNode;
}): ReactElement {
if (!template) {
const Tag = DefaultTag as ElementType;
return (
<Tag {...props} ref={elementRef}>
{children}
</Tag>
);
}
const TemplateTag = template.type as ElementType;
const templateProps = template.props;
const merged: SlotProps = {
...props,
...templateProps,
className: cn(props.className, templateProps.className),
style: { ...props.style, ...(templateProps.style as CSSProperties | undefined) },
};
// Chain duplicated event handlers, template's first (it owns the element).
for (const key of Object.keys(props)) {
if (!/^on[A-Z]/.test(key)) continue;
const ours = props[key];
const theirs = templateProps[key];
if (typeof ours === "function" && typeof theirs === "function") {
merged[key] = (...args: unknown[]) => {
(theirs as (...a: unknown[]) => void)(...args);
(ours as (...a: unknown[]) => void)(...args);
};
}
}
const templateRef =
(templateProps as { ref?: Ref<HTMLElement> }).ref ??
(template as unknown as { ref?: Ref<HTMLElement> }).ref;
// Both refs get the element, through a callback that only runs at commit.
const composedRef = (node: HTMLElement | null) => {
assignRef(elementRef, node);
assignRef(templateRef, node);
};
return (
<TemplateTag key={template.key ?? undefined} {...merged} ref={composedRef}>
{children}
</TemplateTag>
);
}
// ─── SidebarShell (shared desktop DOM for both flavors) ──────────────────────
// Literal map so Tailwind's scanner emits the utilities: for the standard
// breakpoints the shell is also hidden by CSS, avoiding a pre-hydration flash
// of the rail on small screens. Non-standard breakpoints fall back to a
// JS-driven `hidden` in the shell (no dissolve, but never rail + drawer at
// once).
// Crossing the drawer breakpoint DISSOLVES the desktop rail instead of
// snapping it away: opacity fades while `display` rides the same transition
// with allow-discrete, so none applies only once the fade lands (and
// @starting-style fades it back in when the window grows). Literal classes
// per breakpoint — Tailwind's scanner can't see composed strings.
const BREAKPOINT_FADE_BASE =
"transition-[opacity,display] ease-out [transition-behavior:allow-discrete] motion-reduce:transition-none";
const BREAKPOINT_HIDDEN: Record<number, string> = {
640: "max-sm:hidden max-sm:opacity-0 max-sm:duration-160 sm:duration-240 sm:starting:opacity-0",
768: "max-md:hidden max-md:opacity-0 max-md:duration-160 md:duration-240 md:starting:opacity-0",
1024: "max-lg:hidden max-lg:opacity-0 max-lg:duration-160 lg:duration-240 lg:starting:opacity-0",
1280: "max-xl:hidden max-xl:opacity-0 max-xl:duration-160 xl:duration-240 xl:starting:opacity-0",
};
// Props Motion redefines with incompatible signatures; they must not
// be forwarded onto a motion.div.
type MotionSafeDivProps = Omit<
HTMLAttributes<HTMLDivElement>,
| "onDrag"
| "onDragStart"
| "onDragEnd"
| "onAnimationStart"
| "onAnimationEnd"
| "onAnimationIteration"
>;
export interface SidebarShellProps extends MotionSafeDivProps {
side: SidebarSide;
variant: SidebarVariant;
/** The `sidebar` variant's inner-edge border. Default true. */
bordered?: boolean;
/** Render the built-in resize/collapse rail handle. `false` hides it and
* disables drag-resize — the trigger and keyboard shortcut still toggle. */
rail?: boolean;
/** Pin the rail's tooltip open (`true`) or closed (`false`); `undefined`
* leaves it on hover. Dragging always hides it. */
railTooltipOpen?: boolean;
}
/** Internal: the expanded/collapsed desktop rail. An in-flow sticky column
* animates its width (this is what reflows the inset) while the fixed-width
* panel inside slides out under overflow clipping — container-relative, so
* the whole sidebar works inside any bounded frame, not just the viewport.
* Ships the resize/collapse rail handle on its inner edge by default. */
const SidebarShell = forwardRef<HTMLDivElement, SidebarShellProps>(
({ side, variant, bordered = true, rail = true, railTooltipOpen, className, children, ...props }, ref) => {
const {
open,
width,
mobileBreakpoint,
isMobile,
isResizing,
peek,
isPeeking,
setIsPeeking,
schedulePeek,
scheduleDismissPeek,
cancelPeekTimer,
} = useSidebar();
const shape = useShape();
const shellRef = useRef<HTMLDivElement | null>(null);
// Collapsed-peek: the edge strip reveals the sidebar as a floating
// overlay without pinning it. Hover mode uses small intent/leave delays;
// both modes dismiss on Escape or an outside press.
// `!isResizing`: a drag can preview the collapsed state mid-gesture; the
// shell must not swap to the peek strip then, or it would unmount the
// rail holding the pointer capture and kill the drag.
const peekEnabled = peek !== "none" && !open && !isResizing;
useEffect(() => {
if (!(peekEnabled && isPeeking)) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsPeeking(false);
};
const onPointerDown = (event: PointerEvent) => {
if (!shellRef.current?.contains(event.target as Node)) setIsPeeking(false);
};
// Hover mode holds the peek by geometric containment, not
// enter/leave: a portalled tooltip or menu covering the card steals
// the hit-test and fires pointerleave on the shell even though the
// cursor never left the sidebar. Dismissal is armed only when the
// pointer's position actually crosses out of the overlay's box (with
// a little margin), and disarmed the moment it crosses back.
let wasInside = true;
const onPointerMove = (event: PointerEvent) => {
const overlay =
shellRef.current?.querySelector('[data-sidebar="peek"]') ?? shellRef.current;
if (!overlay) return;
const box = overlay.getBoundingClientRect();
const inside =
event.clientX >= box.left - 8 &&
event.clientX <= box.right + 8 &&
event.clientY >= box.top - 8 &&
event.clientY <= box.bottom + 8;
if (inside) {
// Unconditional (not transition-gated): another surface's leave —
// the hover-peek trigger's, say — may have armed a dismissal while
// the pointer was already inside the box.
wasInside = true;
cancelPeekTimer();
} else if (wasInside) {
wasInside = false;
scheduleDismissPeek();
}
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onPointerDown);
if (peek === "hover") document.addEventListener("pointermove", onPointerMove);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("pointermove", onPointerMove);
};
}, [peekEnabled, isPeeking, setIsPeeking, peek, cancelPeekTimer, scheduleDismissPeek]);
const substrate = useSurface();
const floatingLevel = Math.min(substrate + 1, 8);
// Drag-resize needs the panel glued to the pointer; the spring resumes
// for open/close. Reduced motion snaps instead of sliding — the state
// change stays legible without the 256px of travel. The open/close ride
// the SLOW tier: a whole column moving is the largest thing this
// component animates (the sheet and peek stay on moderate — drawers
// settle precisely, per the tier notes).
const reduceMotion = useReducedMotion() ?? false;
// Mid-drag open flips — the collapse preview and its drag-back rescue —
// ride the moderate tier instead of the drag's glued duration-0 tracking.
// The flip is detected synchronously (transition must be right on the
// very commit whose animate target changes; effects run too late), then
// `dragFlip` holds the spring through its settle so pointer moves landing
// right after a flip retarget the spring instead of snapping.
// Counts drag flips, so each one restarts the hold; 0 is no hold.
const [dragFlip, setDragFlip] = useState(0);
// Pinning open from an active peek: the panel is already fully on screen
// as the overlay card, so while the width spring makes room the shell
// must not clip — otherwise the visible sidebar wipes in from a mask it
// never left. The state hold keeps the clip off through the spring.
const [pinFromPeekHold, setPinFromPeekHold] = useState(false);
// Both holds start in the render whose `open` flipped (comparing with
// the last render's `open` and `isPeeking`, kept as state): the provider
// clears isPeeking in that same render, so only the previous render
// still knows the card was up.
const [prevOpen, setPrevOpen] = useState(open);
const [wasPeeking, setWasPeeking] = useState(isPeeking);
if (prevOpen !== open) {
setPrevOpen(open);
if (isResizing) setDragFlip((n) => n + 1);
if (open && wasPeeking) setPinFromPeekHold(true);
}
if (wasPeeking !== isPeeking) setWasPeeking(isPeeking);
if (!isResizing && dragFlip !== 0) setDragFlip(0);
useEffect(() => {
if (dragFlip === 0) return;
const id = setTimeout(() => setDragFlip(0), exitFallbackMs(spring.moderate));
return () => clearTimeout(id);
}, [dragFlip]);
useEffect(() => {
if (!pinFromPeekHold) return;
const id = setTimeout(() => setPinFromPeekHold(false), exitFallbackMs(spring.slow));
return () => clearTimeout(id);
}, [pinFromPeekHold]);
const pinnedFromPeek = pinFromPeekHold;
const widthTransition = reduceMotion
? { duration: 0 }
: isResizing
? dragFlip !== 0
? open
? spring.moderate
: spring.moderate.exit
: { duration: 0 }
: open
? spring.slow
: spring.slow.exit;
return (
<motion.div
ref={(node: HTMLDivElement | null) => {
shellRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
data-slot="sidebar"
data-state={open ? "expanded" : "collapsed"}
data-collapsible={open ? "" : "offcanvas"}
data-variant={variant}
data-side={side}
className={cn(
// No bare `group` here: an unnamed group on the whole rail would
// fire every descendant's group-hover (Button fills, icon strokes)
// on rail hover. Named groups (menu-item etc.) handle row states.
"peer shrink-0 sticky top-0 h-svh",
// While peek is armed the 0-width shell must not clip the edge
// strip or the overlay card — and the shell must rise above the
// inset (a later sibling) so the card paints over it. Pinning from
// a peek keeps both through the width spring for the same reason.
peekEnabled || pinnedFromPeek ? "z-40" : "overflow-hidden",
// Flex order (not DOM order) decides the side, so consumers can
// keep Sidebar before SidebarInset regardless of `side`.
side === "right" && "order-last",
// The inset rail has no card edge of its own, so its scroll hairline
// hugs the rows' 8px gutter instead of running panel-wide.
variant === "inset" && "[--scroll-divider-inset:8px]",
BREAKPOINT_FADE_BASE,
BREAKPOINT_HIDDEN[mobileBreakpoint],
// A non-standard breakpoint has no literal utility in the map, so
// JS hides the shell (no fade, but never rail + drawer at once).
!BREAKPOINT_HIDDEN[mobileBreakpoint] && isMobile && "hidden",
className
)}
initial={false}
animate={{ width: open ? width : "0rem" }}
transition={widthTransition}
// Hover-mode dismissal lives on the shell root: the pointer can land
// on the overlay without ever crossing it (the card slides in under
// a stationary cursor), so per-element leave events are unreliable —
// leaving the shell subtree is the signal that matters.
onPointerEnter={peekEnabled && peek === "hover" ? cancelPeekTimer : undefined}
// While PEEKED, dismissal belongs to the geometric watcher above —
// leave events lie whenever portalled content (tooltip, menu) covers
// the card. This leave handler only retires a pending peek-arm when
// the cursor departs before the intent delay lands.
onPointerLeave={
peekEnabled && peek === "hover"
? () => {
if (!isPeeking) cancelPeekTimer();
}
: undefined
}
{...props}
>
{peekEnabled ? (
<>
{/* Edge strip: the collapsed sidebar's reveal affordance. A thin
hairline brightens on hover; hover mode peeks after a short
intent delay, click mode on press. */}
<button
type="button"
aria-label="Peek sidebar"
aria-expanded={isPeeking}
className={cn(
"group/peek-strip absolute inset-y-0 z-40 w-3 cursor-pointer outline-none",
side === "left" ? "left-0" : "right-0"
)}
onPointerEnter={
peek === "hover"
? (event) => {
if (event.pointerType === "mouse") schedulePeek();
}
: undefined
}
onClick={() => {
cancelPeekTimer();
setIsPeeking(true);
}}
>
<span
aria-hidden="true"
className={cn(
"absolute inset-y-0 w-px bg-border opacity-0 transition-opacity duration-80 group-hover/peek-strip:opacity-100 group-focus-visible/peek-strip:opacity-100",
side === "left" ? "left-0" : "right-0"
)}
/>
</button>
<AnimatePresence>
{isPeeking && (
<motion.div
data-sidebar="peek"
className={cn(
"absolute inset-y-2 z-50 flex flex-col overflow-hidden",
// The card's edge inset matches where the PINNED rail's
// content sits, so pinning from a peek never shifts the
// rows sideways: floating pins into a card inset by the
// same gutter; inset/sidebar pin flush to the edge.
side === "left"
? variant === "floating"
? "left-2"
: "left-0"
: variant === "floating"
? "right-2"
: "right-0",
shape.container,
surfaceClasses(floatingLevel, 3)
)}
style={{
width: `calc(${width} - ${variant === "floating" ? "1rem" : "0.5rem"})`,
}}
initial={reduceMotion ? false : { x: side === "left" ? "-108%" : "108%" }}
animate={{ x: 0 }}
exit={{
x: side === "left" ? "-108%" : "108%",
transition: reduceMotion ? { duration: 0 } : spring.moderate.exit,
}}
transition={reduceMotion ? { duration: 0 } : spring.moderate}
>
<SurfaceProvider value={floatingLevel}>{children}</SurfaceProvider>
</motion.div>
)}
</AnimatePresence>
</>
) : (
<motion.div
className={cn(
"absolute inset-y-0 flex h-full flex-col",
side === "left" ? "left-0" : "right-0",
// Floating floats its card inside a full gutter; inset only needs
// the vertical inset (horizontal room belongs to the nav rows).
variant === "floating" && "p-2",
variant === "inset" && "py-2"
)}
style={{ width }}
initial={false}
animate={{ x: open ? "0%" : side === "left" ? "-100%" : "100%" }}
transition={widthTransition}
>
{variant === "floating" ? (
<div
data-sidebar="sidebar"
className={cn(
"flex h-full w-full min-h-0 flex-col",
shape.container,
surfaceClasses(floatingLevel, 3)
)}
>
<SurfaceProvider value={floatingLevel}>{children}</SurfaceProvider>
</div>
) : (
<div
data-sidebar="sidebar"
className={cn(
"flex h-full w-full min-h-0 flex-col",
bordered &&
variant === "sidebar" &&
(side === "left" ? "border-r border-border" : "border-l border-border")
)}
>
{children}
</div>
)}
{rail && (
<SidebarRail
tooltipOpen={railTooltipOpen}
className={cn(
// The floating card sits inside the panel's p-2 gutter, so the
// grab strip (and its hover hairline) moves in to straddle the
// card's edge instead of the panel's.
variant === "floating" &&
(side === "left" ? "right-1 after:right-[3.5px]" : "left-1 after:left-[3.5px]"),
// Cards are vertically inset and rounded — the hover hairline
// hugs the card's straight run: fully transparent through the
// corner radius, then fading in over 24px (mirrored at the
// bottom). The radius rides the shape system via CSS vars.
variant !== "sidebar" &&
"after:inset-y-2 after:[mask-image:linear-gradient(to_bottom,transparent_var(--rail-fade-start),black_var(--rail-fade-end),black_calc(100%-var(--rail-fade-end)),transparent_calc(100%-var(--rail-fade-start)))]"
)}
style={
variant !== "sidebar"
? ({
"--rail-fade-start": `${shape.bgRadius >= 20 ? 24 : 12}px`,
"--rail-fade-end": `${(shape.bgRadius >= 20 ? 24 : 12) + 24}px`,
} as CSSProperties)
: undefined
}
/>
)}
</motion.div>
)}
</motion.div>
);
}
);
SidebarShell.displayName = "SidebarShell";
// ─── SidebarTrigger ──────────────────────────────────────────────────────────
export type SidebarTriggerProps = ButtonProps;
/** Keystroke chip rendered inside the (inverted) tooltip surface. */
function ShortcutKbd({ children }: { children: ReactNode }) {
return (
<kbd className="-my-1 flex h-4 min-w-4 items-center justify-center rounded border border-background/30 px-1 font-sans text-[length:var(--fs-micro-compact,10px)] leading-[var(--lh-micro-compact,12px)] text-background">
{children}
</kbd>
);
}
/** The tooltips always show the toggle keystroke, falling back to the
* side's default key even when the provider's binding is disabled. */
function useShortcutKey(): string {
const { side, shortcut } = useSidebar();
return (
shortcut ??
(side === "right" ? SIDEBAR_KEYBOARD_SHORTCUT_RIGHT : SIDEBAR_KEYBOARD_SHORTCUT)
);
}
/** Ghost icon button calling toggleSidebar(). The icon mirrors the
* sidebar's side, and its tooltip names the action with the toggle
* keystroke by default. */
const SidebarTrigger = forwardRef<HTMLButtonElement, SidebarTriggerProps>(
({ onClick, size, children, ...props }, ref) => {
const {
toggleSidebar,
open,
openMobile,
isMobile,
side,
peek,
isPeeking,
schedulePeek,
cancelPeekTimer,
} = useSidebar();
const shortcutKey = useShortcutKey();
// With hover-peek enabled, the COLLAPSED trigger is a peek affordance
// too: resting on it floats the rail out exactly like the edge strip —
// same shared intent timer, so moving from the trigger into the peeked
// card (or back) cancels the pending dismissal.
const hoverPeek = peek === "hover" && !isMobile && !open;
const icons = useIcons();
const PanelLeftIcon = icons["panel-left"];
const PanelRightIcon = icons["panel-right"];
const TriggerIcon = side === "right" ? PanelRightIcon : PanelLeftIcon;
const iconSize = useSizeVariant() === "compact" ? ("icon-compact" as const) : ("icon" as const);
const collapsed = isMobile ? !openMobile : !open;
return (
<Tooltip
side="bottom"
content={
<span className="flex items-center gap-1.5">
{/* A flex row escapes the surface's text-box trim, so the label
re-applies it — otherwise the shortcut row would sit taller
than a tooltip without a chip. */}
<span className="[text-box:trim-both_cap_alphabetic]">
{collapsed ? "Expand sidebar" : "Collapse sidebar"}
</span>
<ShortcutKbd>{shortcutKey}</ShortcutKbd>
</span>
}
>
<Button
ref={ref}
variant="ghost"
size={size ?? iconSize}
data-sidebar="trigger"
aria-label="Toggle Sidebar"
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
onPointerEnter={
hoverPeek
? (event: React.PointerEvent) => {
if (event.pointerType !== "mouse") return;
if (isPeeking) cancelPeekTimer();
else schedulePeek();
}
: undefined
}
// While PEEKED the shell's geometric watcher owns dismissal — a
// leave fired here can be the peek card sliding over a stationary
// cursor (layout-driven boundary event, no accompanying move to
// disarm it), which would flicker the peek closed and open again.
// This leave only retires a pending intent timer.
onPointerLeave={
hoverPeek
? () => {
if (!isPeeking) cancelPeekTimer();
}
: undefined
}
{...props}
>
{children ?? <TriggerIcon />}
</Button>
</Tooltip>
);
}
);
SidebarTrigger.displayName = "SidebarTrigger";
// ─── SidebarRail ─────────────────────────────────────────────────────────────
export interface SidebarRailProps extends HTMLAttributes<HTMLButtonElement> {
/** Pin the tooltip open/closed; `undefined` leaves it on hover. */
tooltipOpen?: boolean;
}
/** The grab strip on the sidebar's inner edge, rendered by default inside
* the desktop shell: drag it to resize (clamped), click it to collapse, and
* its tooltip explains both with the toggle keystroke. Hovering it
* brightens the edge border. */
const SidebarRail = forwardRef<HTMLButtonElement, SidebarRailProps>(
({ className, tooltipOpen, ...props }, ref) => {
const { toggleSidebar, setOpen, setWidth, side, setIsResizing } = useSidebar();
const shortcutKey = useShortcutKey();
const railRef = useRef<HTMLButtonElement | null>(null);
const dragRef = useRef<{ startX: number; startWidth: number; moved: boolean; collapsed: boolean } | null>(null);
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
const panel = railRef.current?.closest('[data-slot="sidebar"]') as HTMLElement | null;
if (!panel) return;
dragRef.current = { startX: event.clientX, startWidth: panel.offsetWidth, moved: false, collapsed: false };
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag) return;
const dx = event.clientX - drag.startX;
if (!drag.moved && Math.abs(dx) < 4) return;
if (!drag.moved) {
drag.moved = true;
setDragging(true);
setIsResizing(true);
}
const delta = side === "left" ? dx : -dx;
const raw = drag.startWidth + delta;
// Dragged well past the minimum, toward the edge: preview the collapse
// but keep the drag session alive — pulling back past the threshold
// re-expands, so an overshoot isn't committed until release.
if (raw < SIDEBAR_MIN_WIDTH - SIDEBAR_COLLAPSE_SLOP) {
if (!drag.collapsed) {
drag.collapsed = true;
setWidth(`${SIDEBAR_MIN_WIDTH}px`);
setOpen(false);
}
return;
}
if (drag.collapsed) {
drag.collapsed = false;
setOpen(true);
}
const next = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, raw));
setWidth(`${next}px`);
};
const onPointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
dragRef.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
setIsResizing(false);
// A press that never turned into a drag is the collapse click.
if (drag && !drag.moved) toggleSidebar();
};
// A cancelled pointer (touch interruption, capture loss) ends the drag
// where it stands — no collapse-click, capture is already released.
const onPointerCancel = () => {
dragRef.current = null;
setDragging(false);
setIsResizing(false);
};
const semibold = { fontVariationSettings: fontWeights.semibold };
return (
<Tooltip
side={side === "left" ? "right" : "left"}
sideOffset={8}
followCursor="y"
forceOpen={dragging ? false : tooltipOpen}
content={
// The flex column escapes the tooltip surface's text-box trim, so
// BOTH lines re-apply it and the gap alone carries the line rhythm
// — an untrimmed line would smuggle its half-leading back in as
// lopsided padding.
<span className="flex flex-col items-start gap-2">
<span className="[text-box:trim-both_cap_alphabetic]">
<span style={semibold}>Drag</span> to resize
</span>
<span className="flex items-center gap-1.5">
<span className="[text-box:trim-both_cap_alphabetic]">
<span style={semibold}>Click</span> to collapse
</span>
<ShortcutKbd>{shortcutKey}</ShortcutKbd>
</span>
</span>
}
>
<button
ref={(node) => {
railRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
}}
type="button"
data-sidebar="rail"
aria-label="Resize or collapse sidebar"
tabIndex={-1}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
className={cn(
// touch-none: without it the browser claims a touch drag for
// scrolling and pointercancels the resize mid-gesture.
"absolute inset-y-0 z-20 w-2 cursor-col-resize touch-none outline-none",
// Positioned from context (not group-data selectors) so variant
// offsets passed via className can win the merge.
side === "left" ? "right-0" : "left-0",
// Hovering brightens the edge border the shell draws by default;
// a pinned tooltip brightens it too, so the spotlight reads as
// the hover it stands in for.
"after:absolute after:inset-y-0 after:w-px after:bg-transparent hover:after:bg-foreground/25 after:transition-colors after:duration-80",
tooltipOpen && "after:bg-foreground/25",
side === "left" ? "after:right-0" : "after:left-0",
className
)}
{...props}
/>
</Tooltip>
);
}
);
SidebarRail.displayName = "SidebarRail";
// ─── SidebarInset ────────────────────────────────────────────────────────────
export type SidebarInsetProps = HTMLAttributes<HTMLElement>;
const SidebarInset = forwardRef<HTMLElement, SidebarInsetProps>(
({ className, ...props }, ref) => {
const shape = useShape();
return (
<main
ref={ref}
data-slot="sidebar-inset"
className={cn(
"relative flex min-h-0 w-full min-w-0 flex-1 flex-col bg-background",
"peer-data-[variant=inset]:m-2 peer-data-[variant=inset]:peer-data-[side=left]:ml-0 peer-data-[variant=inset]:peer-data-[side=right]:mr-0",
// With the rail collapsed away, restore the sidebar-side margin so
// the card keeps symmetric insets.
"peer-data-[variant=inset]:peer-data-[state=collapsed]:peer-data-[side=left]:ml-2 peer-data-[variant=inset]:peer-data-[state=collapsed]:peer-data-[side=right]:mr-2",
"transition-[margin] duration-80",
// Container radius follows the shape system (literal classes so
// Tailwind's scanner emits both).
shape.bgRadius >= 20
? "peer-data-[variant=inset]:rounded-3xl"
: "peer-data-[variant=inset]:rounded-xl",
"peer-data-[variant=inset]:bg-surface-2 peer-data-[variant=inset]:shadow-surface-2",
className
)}
{...props}
/>
);
}
);
SidebarInset.displayName = "SidebarInset";
// ─── SidebarInput ────────────────────────────────────────────────────────────
export type SidebarInputProps = React.InputHTMLAttributes<HTMLInputElement>;
const SidebarInput = forwardRef<HTMLInputElement, SidebarInputProps>(
({ className, ...props }, ref) => {
const shape = useShape();
const size = useSize();
return (
<input
ref={ref}
data-sidebar="input"
className={cn(
// Mirrors the InputGroup field ladder: transparent at rest,
// muted fill + border ring on hover, card fill when focused.
"w-full bg-transparent px-3 text-foreground placeholder:text-muted-foreground outline-none",
"ring-1 ring-transparent transition-[background-color,box-shadow] duration-80",
"hover:bg-muted/50 hover:ring-border",
"focus:bg-card focus:ring-border",
"focus-visible:ring-[color:var(--focus-ring,#6B97FF)]",
size.variant === "compact" ? "h-7" : "h-8",
size.field,
shape.input,
className
)}
{...props}
/>
);
}
);
SidebarInput.displayName = "SidebarInput";
// ─── SidebarHeader / SidebarFooter / SidebarSeparator ────────────────────────
export type SidebarSectionProps = HTMLAttributes<HTMLDivElement>;
const SidebarHeader = forwardRef<HTMLDivElement, SidebarSectionProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="header"
className={cn("flex shrink-0 flex-col gap-2 p-2", className)}
{...props}
/>
)
);
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = forwardRef<HTMLDivElement, SidebarSectionProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="footer"
className={cn("mt-auto flex shrink-0 flex-col gap-2 p-2", className)}
{...props}
/>
)
);
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = forwardRef<HTMLDivElement, SidebarSectionProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="separator"
role="separator"
aria-orientation="horizontal"
className={cn("mx-2 h-px shrink-0 bg-border", className)}
{...props}
/>
)
);
SidebarSeparator.displayName = "SidebarSeparator";
// ─── SidebarGroup family ─────────────────────────────────────────────────────
interface SidebarGroupContextValue {
open: boolean;
toggle: () => void;
contentId: string;
/** How many header action buttons overlay the label's right edge — the
* collapsible label pads itself so its chevron clears them. */
actionsCount: number;
}
const SidebarGroupContext = createContext<SidebarGroupContextValue | null>(null);
export interface SidebarGroupProps extends SidebarSectionProps {
/** Makes the group's SidebarGroupLabel a toggle that collapses everything
* rendered after it — a group-level accordion. Uncontrolled by default;
* pass `open`/`onOpenChange` to control it. */
collapsible?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
const SidebarGroup = forwardRef<HTMLDivElement, SidebarGroupProps>(
(
{
className,
collapsible = false,
open: openProp,
defaultOpen = true,
onOpenChange,
children,
...props
},
ref
) => {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const open = openProp ?? uncontrolledOpen;
const contentId = useId();
const toggle = useCallback(() => {
const next = !(openProp ?? uncontrolledOpen);
setUncontrolledOpen(next);
onOpenChange?.(next);
}, [openProp, uncontrolledOpen, onOpenChange]);
// Measured-height collapse: animate between 0 and the content's real
// offsetHeight — never to "auto", which framer measures wrong under a
// scaled ancestor.
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | null>(null);
useIsoLayoutEffect(() => {
if (!collapsible) return;
const el = contentRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const measure = () => setContentHeight(el.offsetHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [collapsible]);
const measured = contentHeight !== null;
// The collapse wrapper must clip while animating, but a permanently
// clipped box shaves the 2px focus ring off a group's first and last
// rows — so clipping lifts once an open group has settled.
const [settled, setSettled] = useState(open);
if (!open && settled) setSettled(false);
// Height animates only when THIS group toggles. When the measured height
// changes underneath it instead — a nested sub-menu collapsing inside the
// group — the wrapper must snap: a spring re-targeted every frame chases
// the child's own animation, lands well after it, and drags everything
// below the group along late. Tracked against the previous `open` so a
// controlled `open` is covered too, and cleared once the toggle's
// animation lands.
const [prevOpen, setPrevOpen] = useState(open);
const [toggling, setToggling] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
setToggling(true);
}
// The label and any header actions stay put; everything else after the
// label rides in the collapse wrapper. If no SidebarGroupLabel child is
// found the group renders untouched.
const isHeaderAction = (k: ReactNode) =>
isValidElement(k) &&
(k.type === SidebarGroupAction || k.type === SidebarGroupActions);
let inner: ReactNode = children;
let actionsCount = 0;
if (collapsible) {
const kids = Children.toArray(children);
const labelIdx = kids.findIndex(
(k) => isValidElement(k) && k.type === SidebarGroupLabel
);
if (labelIdx !== -1) {
const tail = kids.slice(labelIdx + 1);
const headerActions = tail.filter(isHeaderAction);
const rest = tail.filter((k) => !isHeaderAction(k));
actionsCount = headerActions.reduce<number>(
(n, k) =>
n +
(isValidElement(k) && k.type === SidebarGroupActions
? Children.count(
(k.props as { children?: ReactNode }).children
)
: 1),
0
);
inner = (
<>
{kids.slice(0, labelIdx)}
{/* Header hover scope: hovering anywhere on the row — the
overlaid action cluster included, which never :hovers the
label element itself — reveals the label's chevron. Kept
position-static so the cluster's absolute box still anchors
to the group and stays on the rows' action axis. */}
<div className="group/group-header w-full">
{kids[labelIdx]}
{headerActions}
</div>
<motion.div
id={contentId}
aria-hidden={open ? undefined : true}
className={cn(
open && settled ? "overflow-visible" : "overflow-hidden",
!measured && !open && "h-0"
)}
initial={false}
animate={
measured
? { height: open ? contentHeight : 0, opacity: open ? 1 : 0 }
: { opacity: open ? 1 : 0 }
}
// Do NOT simplify this to `open ? spring.moderate : …`. The
// toggling arm is what stops a re-measure from springing —
// without it a nested collapse makes this wrapper chase its own
// child and everything below the group moves late.
transition={
toggling
? open
? spring.moderate
: spring.moderate.exit
: { duration: 0 }
}
onAnimationComplete={() => {
setToggling(false);
if (open) setSettled(true);
}}
>
<div ref={contentRef} className="flex w-full min-w-0 flex-col">
{rest}
</div>
</motion.div>
</>
);
}
}
const ctx = useMemo(
() => ({ open, toggle, contentId, actionsCount }),
[open, toggle, contentId, actionsCount]
);
return (
<div
ref={ref}
data-sidebar="group"
data-state={collapsible ? (open ? "open" : "closed") : undefined}
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
>
<SidebarGroupContext.Provider value={collapsible ? ctx : null}>
{inner}
</SidebarGroupContext.Provider>
</div>
);
}
);
SidebarGroup.displayName = "SidebarGroup";
export interface SidebarGroupLabelProps extends HTMLAttributes<HTMLDivElement> {
render?: ReactElement;
asChild?: boolean;
}
const SidebarGroupLabel = forwardRef<HTMLDivElement, SidebarGroupLabelProps>(
({ className, render, asChild, children, ...props }, ref) => {
const sizeVariant = useSizeVariant();
const sizeClasses = useSize();
const group = useContext(SidebarGroupContext);
const shape = useShape();
const icons = useIcons();
const ChevronRightIcon = icons["chevron-right"];
const { template, content } = resolveSlotTemplate(render, asChild, children);
// Truncate only the leading text; element children (count badges,
// trailing controls) stay flex siblings so the row's gap keeps spacing
// them — same split MenuRowLabel does for menu rows.
const nodes = Children.toArray(content);
let textEnd = 0;
while (
textEnd < nodes.length &&
(typeof nodes[textEnd] === "string" || typeof nodes[textEnd] === "number")
) {
textEnd++;
}
const leadingText = nodes.slice(0, textEnd).join("");
const labelContent = leadingText ? (
<>
<span className="min-w-0 truncate">{leadingText}</span>
{nodes.slice(textEnd)}
</>
) : (
content
);
// Inside a collapsible group the label becomes the toggle. Design
// treatment is unchanged — hover only raises the label's contrast and
// reveals a chevron (kept visible while collapsed as the reopen cue).
if (group) {
return (
<SlotElement
template={template}
defaultTag="button"
elementRef={ref as Ref<HTMLElement>}
props={{
type: template ? undefined : "button",
"data-sidebar": "group-label",
"aria-expanded": group.open,
"aria-controls": group.contentId,
onClick: group.toggle,
// The action cluster overlays the label's right edge, so the label
// pads past it — far enough that the hover-revealed chevron lands
// one cluster gap (4px) to its left and the whole trailing run
// keeps a single rhythm. Cluster width is 24px per action plus 4px
// between them; add that gap again, less the 8px the group's
// padding already gives back: 28n + 6. The cluster is always
// visible, so the reservation is permanent.
style:
group.actionsCount > 0
? ({ "--group-actions-pad": `${group.actionsCount * 28 + 6}px` } as CSSProperties)
: undefined,
className: cn(
"flex h-8 w-full shrink-0 cursor-pointer select-none items-center gap-2 px-2 text-left text-muted-foreground outline-none",
"transition-colors duration-80 hover:text-muted-foreground",
group.actionsCount > 0 && "pr-[var(--group-actions-pad)]",
"focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]",
shape.item,
typeClass("caption", sizeVariant),
className
),
...props,
}}
>
<>
{labelContent}
{/* The chevron occupies an action-sized box, so it reads as one more
icon in the row rather than a smaller glyph tacked on the end.
One chevron-right glyph for both states, sprung 90° to point
down while the group is open — the motion wrapper is what
animates: Tailwind's rotate-* sets the standalone CSS `rotate`
property, which transition-transform never covers. While open
the whole box collapses to zero width at rest so the label text
keeps the full row; hover/focus (or an open action popup)
reveals it. Collapsed keeps it visible as the reopen cue. */}
{/* No width/opacity transition: animating the box's width slides
the glyph in from the side — the chevron should simply be
there once the header is hovered. */}
<span
className={cn(
"ml-auto flex h-6 shrink-0 items-center justify-center overflow-hidden",
group.open
? "w-0 opacity-0 group-hover/group-header:w-6 group-hover/group-header:opacity-100 group-focus-within/group-header:w-6 group-focus-within/group-header:opacity-100 group-has-[[data-sidebar=group-action]:is([data-state=open],[data-popup-open],[aria-expanded=true])]/group-header:w-6 group-has-[[data-sidebar=group-action]:is([data-state=open],[data-popup-open],[aria-expanded=true])]/group-header:opacity-100 pointer-coarse:w-6 pointer-coarse:opacity-100"
: "w-6 opacity-100"
)}
>
<motion.span
className="inline-flex"
animate={{ rotate: group.open ? 90 : 0 }}
transition={spring.fast}
>
<ChevronRightIcon
size={sizeClasses.icon}
strokeWidth={1.5}
className="shrink-0"
/>
</motion.span>
</span>
</>
</SlotElement>
);
}
return (
<SlotElement
template={template}
defaultTag="div"
elementRef={ref as Ref<HTMLElement>}
props={{
"data-sidebar": "group-label",
className: cn(
"flex h-8 shrink-0 items-center gap-2 px-2 text-muted-foreground outline-none",
typeClass("caption", sizeVariant),
className
),
...props,
}}
>
{labelContent}
</SlotElement>
);
}
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
export interface SidebarGroupActionProps extends HTMLAttributes<HTMLButtonElement> {
render?: ReactElement;
asChild?: boolean;
}
/** True while rendering inside a SidebarGroupActions cluster, where each
* action sits in the flex row instead of positioning itself absolutely. */
const GroupActionsContext = createContext(false);
const SidebarGroupAction = forwardRef<HTMLButtonElement, SidebarGroupActionProps>(
({ className, render, asChild, children, ...props }, ref) => {
const shape = useShape();
const sizeClasses = useSize();
const inCluster = useContext(GroupActionsContext);
const { template, content } = resolveSlotTemplate(render, asChild, children);
return (
<SlotElement
template={template}
defaultTag="button"
elementRef={ref as Ref<HTMLElement>}
props={{
type: template ? undefined : "button",
"data-sidebar": "group-action",
className: cn(
inCluster
? "relative flex size-6 items-center justify-center text-muted-foreground outline-none"
// size-6 matches the rows' action hit-box, and right-3.5 puts that
// 24px box's centre 26px from the sidebar's inner edge — the axis
// the rows' badges and actions already sit on.
: "absolute right-3.5 top-3 flex size-6 items-center justify-center text-muted-foreground outline-none",
"hover:bg-hover hover:text-foreground transition-colors duration-80",
"focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]",
// Normalize bare icons to the site's 1.5 stroke (library defaults
// vary), thickening to 2 on hover — the same treatment Button's
// icon-only span applies.
"[&_svg]:size-[var(--icon-size)] [&_svg]:shrink-0 [&_svg]:stroke-[1.5] [&_svg]:transition-[stroke-width] [&_svg]:duration-80 hover:[&_svg]:stroke-[2]",
shape.item,
className
),
...props,
// After ...props: the spread would otherwise replace this object
// wholesale and drop the icon-size the glyph is sized from.
style: {
...({ "--icon-size": `${sizeClasses.icon}px` } as CSSProperties),
...(props.style ?? {}),
},
}}
>
{content}
</SlotElement>
);
}
);
SidebarGroupAction.displayName = "SidebarGroupAction";
/** Header action cluster: 1–3 SidebarGroupActions laid out in a row over the
* group label's right edge. Use instead of a lone SidebarGroupAction when a
* section needs several controls. */
export type SidebarGroupActionsProps = HTMLAttributes<HTMLDivElement>;
const SidebarGroupActions = forwardRef<HTMLDivElement, SidebarGroupActionsProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group-actions"
className={cn(
// right-3.5 lands the last 24px action's centre 26px from the
// sidebar's inner edge — the rows' badge/action axis, so the header's
// controls line up with the column below them.
"absolute right-3.5 top-2 z-10 flex h-8 items-center gap-1",
className
)}
{...props}
>
<GroupActionsContext.Provider value={true}>
{children}
</GroupActionsContext.Provider>
</div>
);
}
);
SidebarGroupActions.displayName = "SidebarGroupActions";
const SidebarGroupContent = forwardRef<HTMLDivElement, SidebarSectionProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full", className)}
{...props}
/>
)
);
SidebarGroupContent.displayName = "SidebarGroupContent";
export {
SidebarProvider,
SidebarShell,
SidebarTrigger,
SidebarRail,
SidebarInset,
SidebarInput,
SidebarHeader,
SidebarFooter,
SidebarSeparator,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupAction,
SidebarGroupActions,
SidebarGroupContent,
};
registry/default/sidebar-menu.tsx
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
useRef,
useId,
forwardRef,
Children,
type ReactNode,
type ReactElement,
type CSSProperties,
type HTMLAttributes,
type LiHTMLAttributes,
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type Ref,
type RefObject,
} from "react";
import { motion, AnimatePresence } from "motion/react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
import { fontWeights } from "@/lib/font-weight";
import { useShape } from "@/lib/shape-context";
import { useSize, SizeProvider, typeClass, type SizeVariant } from "@/lib/size-context";
import { useFluidHover, type ItemRect } from "@/hooks/use-fluid-hover";
import type { IconComponent } from "@/lib/icon-context";
import { resolveSlotTemplate, SlotElement } from "@/components/ui/sidebar-core";
import { FluidHoverHighlight } from "@/components/ui/fluid-hover-highlight";
// SSR-safe layout effect (client components still server-render in Next).
const useIsoLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
// ─── Menu scope ──────────────────────────────────────────────────────────────
//
// One scope per SidebarMenu tree: a single fluid-hover system plus the
// traveling overlays — hover background, active background(s), focus ring —
// that glide between every visible row, sub-menu rows included, so the hover
// moves from a parent into its children as one continuous piece. Sub rows
// live inside positioned ancestors, so their rects are accumulated into the
// menu's own coordinate space by the fluid hover hook. The active background
// stays one per level (the root rows, and each sub-menu) so a current section
// and the current page inside it can both be lit, exactly as before.
interface MenuScopeValue {
registerRow: (el: HTMLElement) => () => void;
setRowButton: (row: HTMLElement, button: HTMLElement | null) => void;
setRowActive: (row: HTMLElement, active: boolean) => void;
hoveredRowEl: HTMLElement | null;
/** Every visible active row, in DOM order — a parent section marker and
* the current row inside its sub-tree can be active at once. */
activeRows: HTMLElement[];
firstRowEl: HTMLElement | null;
hasActive: boolean;
/** A sub-menu toggled: rows changed visibility in place, so hover targets
* and the visible active set must be recomputed. */
refreshVisibility: () => void;
}
const MenuScopeContext = createContext<MenuScopeValue | null>(null);
interface MenuItemContextValue {
rowRef: RefObject<HTMLLIElement | null>;
/** Ref callback for the row's <li> — also replays the row's active flag
* to the scope, covering the windows where the ref is detached. */
attachRow: (node: HTMLLIElement | null) => void;
isHovered: boolean;
isActiveRow: boolean;
/** True inside SidebarMenuSubItem — actions center on the shorter row. */
isSubRow: boolean;
setActive: (active: boolean) => void;
setButtonEl: (el: HTMLElement | null) => void;
/** Trailing controls on this row, registered by the action / badge parts.
* The button turns them into an exact padding-right reservation. */
actionCount: number;
actionsShowOnHover: boolean;
hasBadge: boolean;
setActions: (count: number, showOnHover: boolean) => void;
setHasBadge: (hasBadge: boolean) => void;
}
const MenuItemContext = createContext<MenuItemContextValue | null>(null);
/** True while rendering inside a SidebarMenuActions cluster, where each
* action flows in the wrapper's row instead of positioning itself. */
const MenuActionsClusterContext = createContext(false);
/** True while the element sits inside a collapsed sub-tree — clipped away,
* so it must be invisible to hover, highlights, and keyboard order. Rows
* stay registered either way: unregistering on every toggle would churn the
* fluid hover measurements and blink the overlays. */
function rowHidden(el: HTMLElement) {
return el.closest('[data-sidebar="menu-sub"][data-state="closed"]') !== null;
}
/** True for a disabled row: it stays in the list (and in the layout) but is
* never lit, never takes a routed click, and is skipped by the keyboard.
* The hook registers the row's button (falling back to the row itself), so
* check the element first, then a button directly inside it. A sub-row's
* anchor carries aria-disabled instead of `disabled`. */
const DISABLED_CONTROL = ':disabled, [aria-disabled="true"]';
function rowDisabled(el: HTMLElement) {
return (
el.matches(DISABLED_CONTROL) ||
el.querySelector(
`:scope > [data-sidebar="menu-button"]:is(${DISABLED_CONTROL}), :scope > [data-sidebar="menu-sub-button"]:is(${DISABLED_CONTROL})`
) !== null
);
}
/** Hidden or disabled: what hover, highlights, and the keyboard skip. */
function rowSkipped(el: HTMLElement) {
return rowHidden(el) || rowDisabled(el);
}
/** Stable keys for the per-level active overlays: one id per sub-menu <ul>
* (or the menu root), so the active background glides when the active row
* moves within its level instead of remounting. The root level is 0 in
* every menu (sub-menu ids start at 1): rows register before the menu's
* own ref attaches, so the root can't be looked up by element. */
const ROOT_LEVEL_ID = 0;
let overlayGroupSeq = 0;
const overlayGroupIds = new WeakMap<Element, number>();
function overlayGroupId(el: Element) {
let id = overlayGroupIds.get(el);
if (id === undefined) {
id = ++overlayGroupSeq;
overlayGroupIds.set(el, id);
}
return id;
}
function byDomOrder(a: HTMLElement, b: HTMLElement) {
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
}
function sameElements(a: HTMLElement[], b: HTMLElement[]) {
return a.length === b.length && a.every((el, i) => el === b[i]);
}
/** What the overlays need to know about a row, read from the DOM when the
* row set syncs rather than during render: its level's overlay id (the
* root, or the sub-menu it sits in) and whether it was registered without a
* button (so the hook measured its whole <li>). */
interface RowInfo {
levelId: number;
buttonless: boolean;
}
function sameRowInfo(a: Map<HTMLElement, RowInfo>, b: Map<HTMLElement, RowInfo>) {
if (a.size !== b.size) return false;
for (const [row, info] of b) {
const prev = a.get(row);
if (!prev || prev.levelId !== info.levelId || prev.buttonless !== info.buttonless)
return false;
}
return true;
}
function sameRect(a: ItemRect | null, b: ItemRect | null) {
if (a === b) return true;
if (!a || !b) return false;
return a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;
}
/** An overlay's target as of its last change, and whether that change moved
* it to a different row (spring) or only moved its row (snap). */
interface OverlayTarget {
row: HTMLElement | null;
rect: ItemRect | null;
rowChanged: boolean;
}
const NO_TARGET: OverlayTarget = { row: null, rect: null, rowChanged: false };
function retarget(prev: OverlayTarget, row: HTMLElement | null, rect: ItemRect | null) {
if (prev.row === row && sameRect(prev.rect, rect)) return prev;
return { row, rect, rowChanged: prev.row !== row };
}
/** retarget over the keyed active overlays; the same map when none moved. */
function retargetAll(
prev: Map<string, OverlayTarget>,
next: { key: string; row: HTMLElement; rect: ItemRect }[]
) {
let changed = prev.size !== next.length;
const out = new Map<string, OverlayTarget>();
for (const { key, row, rect } of next) {
const before = prev.get(key);
const after = retarget(before ?? NO_TARGET, row, rect);
if (after !== before) changed = true;
out.set(key, after);
}
return changed ? out : prev;
}
interface MenuScope {
value: MenuScopeValue;
containerProps: {
onMouseEnter: () => void;
onMouseMove: (e: React.MouseEvent) => void;
onMouseLeave: () => void;
/** A click between rows lands on the highlighted row's button. */
onClick: (e: React.MouseEvent) => void;
onFocus: (e: React.FocusEvent) => void;
onBlur: (e: React.FocusEvent) => void;
onPointerDown: () => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
};
overlays: ReactNode;
}
interface MenuScopeOptions {
/** Draw the traveling keyboard focus ring. Off, keyboard focus moves the
* hover background only — for menus whose rows are the whole surface,
* like a settings dialog's section list. @default true */
focusRing?: boolean;
}
function useMenuScope(
containerRef: RefObject<HTMLElement | null>,
{ focusRing = true }: MenuScopeOptions = {}
): MenuScope {
const {
activeIndex,
setActiveIndex,
itemRects,
isMeasured,
session,
handlers,
registerItem,
} = useFluidHover(containerRef, { isItemDisabled: rowSkipped });
const rowsRef = useRef<Set<HTMLElement>>(new Set());
const rowButtonsRef = useRef<Map<HTMLElement, HTMLElement>>(new Map());
const activeMapRef = useRef<Map<HTMLElement, boolean>>(new Map());
const [orderedRows, setOrderedRows] = useState<HTMLElement[]>([]);
// syncRows keeps this current the moment the row set changes; the state
// above follows on the next render.
const orderedRowsRef = useRef(orderedRows);
const [rowInfo, setRowInfo] = useState<Map<HTMLElement, RowInfo>>(() => new Map());
const registeredCountRef = useRef(0);
const [activeRows, setActiveRows] = useState<HTMLElement[]>([]);
const [focusedRowEl, setFocusedRowEl] = useState<HTMLElement | null>(null);
const recomputeActive = useCallback(() => {
const next = orderedRowsRef.current.filter(
(el) => activeMapRef.current.get(el) && !rowSkipped(el)
);
setActiveRows((prev) => (sameElements(prev, next) ? prev : next));
}, []);
const rowButton = useCallback(
(row: HTMLElement) =>
rowButtonsRef.current.get(row) ??
row.querySelector<HTMLElement>(
':scope > [data-sidebar="menu-button"], :scope > [data-sidebar="menu-sub-button"]'
),
[]
);
// Rows register by element; indexes are derived from DOM order so consumers
// never pass an index prop and conditional rows just work. The fluid hover
// system measures the row's BUTTON, not the <li>: a row hosting an expanded
// sub-tree is a tall <li>, and hit-testing against that whole box would hand
// the sub-tree's gaps and gutter to the parent — the button strip is the
// only part that is really "the row".
const syncRows = useCallback(() => {
const sorted = [...rowsRef.current].sort(byDomOrder);
// The ref updates synchronously (not just at the next render): callers in
// the same commit — a row registering, its button turning active — must
// see the row set they just changed, or the first recompute of a mount
// filters every row out against the previous render's empty list.
orderedRowsRef.current = sorted;
setOrderedRows((prev) => (sameElements(prev, sorted) ? prev : sorted));
// Every active row gets its own background, keyed by the row's level
// (root, or its sub-menu): see activeRects below.
const info = new Map<HTMLElement, RowInfo>();
for (const el of sorted) {
const sub = el.closest('[data-sidebar="menu-sub"]');
info.set(el, {
levelId: sub ? overlayGroupId(sub) : ROOT_LEVEL_ID,
buttonless: !rowButton(el),
});
}
setRowInfo((prev) => (sameRowInfo(prev, info) ? prev : info));
sorted.forEach((el, i) => registerItem(i, rowButton(el) ?? el));
for (let i = sorted.length; i < registeredCountRef.current; i++) {
registerItem(i, null);
}
registeredCountRef.current = sorted.length;
recomputeActive();
}, [registerItem, recomputeActive, rowButton]);
const registerRow = useCallback(
(el: HTMLElement) => {
rowsRef.current.add(el);
syncRows();
return () => {
rowsRef.current.delete(el);
rowButtonsRef.current.delete(el);
activeMapRef.current.delete(el);
syncRows();
};
},
[syncRows]
);
const setRowButton = useCallback(
(row: HTMLElement, button: HTMLElement | null) => {
if (button) rowButtonsRef.current.set(row, button);
else rowButtonsRef.current.delete(row);
// The button is the row's measured element, so a button arriving after
// its row registered must re-sync what the fluid hover system observes.
syncRows();
},
[syncRows]
);
const setRowActive = useCallback(
(row: HTMLElement, active: boolean) => {
activeMapRef.current.set(row, active);
recomputeActive();
},
[recomputeActive]
);
const refreshVisibility = useCallback(() => {
recomputeActive();
// A hover riding a row that just collapsed away has nothing under it.
setActiveIndex((prev) => {
const row = prev !== null ? orderedRowsRef.current[prev] : undefined;
return row && rowSkipped(row) ? null : prev;
});
}, [recomputeActive, setActiveIndex]);
// The hook measures each row's button, so a rect is the button strip. A
// row registered without one was measured as its whole <li> — which grows
// when it hosts an expanded sub-menu — so that rect is clamped to 48px
// (the tallest row, size="lg"): the highlight can never cover an expanded
// sub-tree.
const overlayRect = (row: HTMLElement | null): ItemRect | null => {
if (!row) return null;
const idx = orderedRows.indexOf(row);
const rect = idx === -1 ? null : itemRects[idx];
if (!rect) return null;
return rowInfo.get(row)?.buttonless ? { ...rect, height: Math.min(rect.height, 48) } : rect;
};
// While a popup anchored in the sidebar is open (a row action's or the
// header/footer rows' dropdown), hover tracking freezes across every menu
// scope — otherwise a non-modal popup lets rows underneath keep
// highlighting. Popup triggers are detected by the primitives' open
// attributes (Radix data-state, Base UI data-popup-open); collapsible rows
// only set aria-expanded, so they never match.
const popupOpen = useCallback(() => {
const container = containerRef.current;
if (!container) return false;
const root = container.closest('[data-slot="sidebar-wrapper"]') ?? container;
return !!root.querySelector(
'[data-sidebar="menu-button"][data-state="open"], [data-sidebar="menu-button"][data-popup-open], [data-sidebar="menu-action"][data-state="open"], [data-sidebar="menu-action"][data-popup-open]'
);
}, [containerRef]);
const onMouseMove = useCallback(
(e: React.MouseEvent) => {
if (popupOpen()) return;
handlers.onMouseMove(e);
},
[popupOpen, handlers]
);
const onFocus = useCallback(
(e: React.FocusEvent) => {
const target = e.target as HTMLElement;
// Only the row's main button drives the traveling highlight and ring —
// actions keep their own static focus rings.
if (!target.closest('[data-sidebar="menu-button"],[data-sidebar="menu-sub-button"]')) return;
const row = target.closest(
'[data-sidebar="menu-item"],[data-sidebar="menu-sub-item"]'
) as HTMLElement | null;
if (!row) return;
const idx = orderedRowsRef.current.indexOf(row);
if (idx === -1) return;
setActiveIndex(idx);
setFocusedRowEl(target.matches(":focus-visible") ? row : null);
},
[setActiveIndex]
);
const onPointerDown = useCallback(() => {
setFocusedRowEl(null);
}, []);
const onBlur = useCallback(
(e: React.FocusEvent) => {
if (containerRef.current?.contains(e.relatedTarget as Node)) return;
setFocusedRowEl(null);
setActiveIndex(null);
},
[containerRef, setActiveIndex]
);
// Arrow/Home/End over every button in DOM order, sub rows included — only
// the root scope binds it so nested scopes don't double-handle.
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft", "Home", "End"].includes(e.key))
return;
const container = containerRef.current;
if (!container) return;
const items = Array.from(
container.querySelectorAll<HTMLElement>(
'[data-sidebar="menu-button"], [data-sidebar="menu-sub-button"]'
)
).filter((el) => !el.closest('[data-sidebar="menu-sub"][data-state="closed"]'));
const currentIdx = items.indexOf(e.target as HTMLElement);
if (currentIdx === -1) return;
e.preventDefault();
// Keep handled arrows from also reaching window-level listeners (the
// docs site's ←/→ page navigation) — same rule as AskUserQuestions.
e.stopPropagation();
if (e.key === "Home") items[0]?.focus();
else if (e.key === "End") items[items.length - 1]?.focus();
else {
const next = ["ArrowDown", "ArrowRight"].includes(e.key)
? (currentIdx + 1) % items.length
: (currentIdx - 1 + items.length) % items.length;
items[next]?.focus();
}
},
[containerRef]
);
const hoveredRowEl = activeIndex !== null ? orderedRows[activeIndex] ?? null : null;
const value = useMemo<MenuScopeValue>(
() => ({
registerRow,
setRowButton,
setRowActive,
hoveredRowEl,
activeRows,
firstRowEl: orderedRows[0] ?? null,
hasActive: activeRows.length > 0,
refreshVisibility,
}),
[registerRow, setRowButton, setRowActive, hoveredRowEl, activeRows, orderedRows, refreshVisibility]
);
const shape = useShape();
// Every active row gets its own background — the buttons' own text styling
// already lights each active row, so the overlays must match. Keys are the
// row's level (root, or its sub-menu) plus its occurrence within that
// level: the usual case — one active per level, e.g. a current section
// marker plus the current page inside its sub-tree — keeps a stable key,
// so the background GLIDES when the selection moves instead of remounting.
const levelOccurrence = new Map<number, number>();
const activeRects: { key: string; rect: ItemRect; row: HTMLElement }[] = [];
for (const row of activeRows) {
const levelId = rowInfo.get(row)?.levelId;
if (levelId === undefined) continue;
const occurrence = levelOccurrence.get(levelId) ?? 0;
levelOccurrence.set(levelId, occurrence + 1);
const rect = overlayRect(row);
if (rect) activeRects.push({ key: `${levelId}:${occurrence}`, rect, row });
}
const hoverRect = overlayRect(hoveredRowEl);
const focusRect = focusRing ? overlayRect(focusedRowEl) : null;
// A rect change has two causes with two right answers. The highlight moving
// to a DIFFERENT row springs — that's the glide. The same row itself moving
// — a sibling sub-tree collapsing above reflows every row below on every
// frame of its own spring — must snap, or the overlay chases the row it is
// sitting on with a trailing second spring. Each overlay keeps its target
// as of its last change, and which kind of change that was; updated during
// render, so the render that moves an overlay already carries the answer.
const [targets, setTargets] = useState<{
hover: OverlayTarget;
focus: OverlayTarget;
actives: Map<string, OverlayTarget>;
}>(() => ({ hover: NO_TARGET, focus: NO_TARGET, actives: new Map() }));
const hoverTarget = retarget(targets.hover, hoveredRowEl, hoverRect);
const focusTarget = retarget(targets.focus, focusedRowEl, focusRect);
const activeTargets = retargetAll(targets.actives, activeRects);
if (
hoverTarget !== targets.hover ||
focusTarget !== targets.focus ||
activeTargets !== targets.actives
) {
setTargets({ hover: hoverTarget, focus: focusTarget, actives: activeTargets });
}
const hoverRowChanged = hoverTarget.rowChanged;
const focusRowChanged = focusTarget.rowChanged;
const overlays = isMeasured ? (
<>
{/* Active row backgrounds — one per active row (see activeRects above) */}
<AnimatePresence>
{activeRects.map(({ key, rect }) => (
<motion.div
key={key}
className={`absolute ${shape.bg} bg-active pointer-events-none`}
initial={false}
animate={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
opacity: 1,
}}
exit={{ opacity: 0, transition: spring.moderate.exit }}
transition={
activeTargets.get(key)?.rowChanged
? { ...spring.moderate, opacity: { duration: 0.08 } }
: { duration: 0 }
}
/>
))}
</AnimatePresence>
{/* Hover background. Fades in at the first hovered row; snaps (no
travel) when only a reflow moved the rows underneath. */}
<FluidHoverHighlight
rect={hoverRect}
session={session}
className={shape.bg}
transition={hoverRowChanged ? undefined : false}
/>
{/* Focus ring */}
<AnimatePresence>
{focusRect && (
<motion.div
className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[color:var(--focus-ring,#6B97FF)]`}
initial={false}
animate={{
left: focusRect.left - 2,
top: focusRect.top - 2,
width: focusRect.width + 4,
height: focusRect.height + 4,
}}
exit={{ opacity: 0, transition: spring.fast.exit }}
transition={
focusRowChanged
? { ...spring.fast, opacity: { duration: 0.08 } }
: { duration: 0 }
}
/>
)}
</AnimatePresence>
</>
) : null;
return {
value,
containerProps: {
onMouseEnter: handlers.onMouseEnter,
onMouseMove,
onMouseLeave: handlers.onMouseLeave,
onClick: handlers.onClick,
onFocus,
onBlur,
// Pointer interaction switches modality back to pointer. Clicking the
// already-focused row never re-fires focus, so without this the
// keyboard ring would stick until focus left the menu.
onPointerDown,
onKeyDown,
},
overlays,
};
}
// ─── SidebarMenu ─────────────────────────────────────────────────────────────
export interface SidebarMenuProps extends HTMLAttributes<HTMLUListElement> {
/** Pins the menu's rows to one step of the size ladder. Omitted, they
* follow the surrounding SizeProvider. */
size?: SizeVariant;
/** Draw the traveling keyboard focus ring. Off, keyboard focus moves the
* hover background only. @default true */
focusRing?: boolean;
}
const SidebarMenu = forwardRef<HTMLUListElement, SidebarMenuProps>(
({ className, size, focusRing, children, ...props }, ref) => {
const containerRef = useRef<HTMLUListElement>(null);
const { value, containerProps, overlays } = useMenuScope(containerRef, { focusRing });
const content = (
<MenuScopeContext.Provider value={value}>
<ul
ref={(node) => {
containerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLUListElement | null>).current = node;
}}
data-sidebar="menu"
className={cn("relative flex w-full min-w-0 flex-col select-none", className)}
{...containerProps}
{...props}
>
{overlays}
{children}
</ul>
</MenuScopeContext.Provider>
);
return size ? <SizeProvider size={size}>{content}</SizeProvider> : content;
}
);
SidebarMenu.displayName = "SidebarMenu";
// ─── SidebarMenuItem / SidebarMenuSubItem ────────────────────────────────────
export type SidebarMenuItemProps = LiHTMLAttributes<HTMLLIElement>;
function useMenuRow(rowRef: RefObject<HTMLLIElement | null>, isSubRow = false) {
const scope = useContext(MenuScopeContext);
const registerRow = scope?.registerRow;
const setRowButton = scope?.setRowButton;
const setRowActive = scope?.setRowActive;
// The button's setActive effect can fire while this row's <li> ref is
// detached: a child's layout effects run before its parent's ref attaches
// — at mount, and on EVERY re-render whose inline ref identity changes
// (React detaches the old callback, nulling the ref, before the layout
// phase). The flag holds the truth through that window, and attachRow
// re-syncs the scope whenever the <li> lands.
const activeFlagRef = useRef(false);
useIsoLayoutEffect(() => {
const el = rowRef.current;
if (!el || !registerRow) return;
return registerRow(el);
}, [registerRow, rowRef]);
// The <li> as state too, for the render's "am I the hovered / an active
// row" checks against the scope's elements.
const [rowEl, setRowEl] = useState<HTMLLIElement | null>(null);
/** The <li>'s ref callback: tracks the element and replays the active flag
* the scope may have missed while the ref was detached. */
const attachRow = useCallback(
(node: HTMLLIElement | null) => {
rowRef.current = node;
setRowEl(node);
if (node && setRowActive) setRowActive(node, activeFlagRef.current);
},
[setRowActive, rowRef]
);
const setActive = useCallback(
(active: boolean) => {
activeFlagRef.current = active;
if (rowRef.current && setRowActive) setRowActive(rowRef.current, active);
},
[setRowActive, rowRef]
);
const setButtonEl = useCallback(
(el: HTMLElement | null) => {
if (rowRef.current && setRowButton) setRowButton(rowRef.current, el);
},
[setRowButton, rowRef]
);
const isHovered = rowEl !== null && scope?.hoveredRowEl === rowEl;
const isActiveRow = rowEl !== null && (scope?.activeRows.includes(rowEl) ?? false);
const [trailing, setTrailing] = useState({
actionCount: 0,
actionsShowOnHover: false,
hasBadge: false,
});
const setActions = useCallback(
(count: number, showOnHover: boolean) =>
setTrailing((prev) =>
prev.actionCount === count && prev.actionsShowOnHover === showOnHover
? prev
: { ...prev, actionCount: count, actionsShowOnHover: showOnHover }
),
[]
);
const setHasBadge = useCallback(
(hasBadge: boolean) =>
setTrailing((prev) => (prev.hasBadge === hasBadge ? prev : { ...prev, hasBadge })),
[]
);
return useMemo(
() => ({
rowRef,
attachRow,
isHovered,
isActiveRow,
isSubRow,
setActive,
setButtonEl,
...trailing,
setActions,
setHasBadge,
}),
[
rowRef,
attachRow,
isHovered,
isActiveRow,
isSubRow,
setActive,
setButtonEl,
trailing,
setActions,
setHasBadge,
]
);
}
// ─── Trailing-gutter math ────────────────────────────────────────────────────
//
// The label reserves exactly the trailing run it has to clear, plus one gap
// — the same rule the section header's label follows, so a row's chevron and
// a section header's chevron each sit one 4px gap from their action run.
// A run is: the badge's 24px slot (rightmost when present), the action
// cluster (24px apiece, 4px between), and a gap where both appear.
const ROW_BASE_PAD = 8;
const ROW_SLOT = 24;
const ROW_GAP = 4;
/** Where the run's rightmost element sits, measured from the row's right
* edge: a badge at right-2, an action cluster at right-1.5 (its wider box
* puts both on the same centre line). */
const ROW_BADGE_INSET = 8;
const ROW_ACTION_INSET = 6;
function rowGutter(actionCount: number, hasBadge: boolean) {
if (!actionCount && !hasBadge) return ROW_BASE_PAD;
const actionsWidth = actionCount
? actionCount * ROW_SLOT + (actionCount - 1) * ROW_GAP
: 0;
const runWidth =
(hasBadge ? ROW_SLOT : 0) +
actionsWidth +
(hasBadge && actionCount ? ROW_GAP : 0);
const inset = hasBadge ? ROW_BADGE_INSET : ROW_ACTION_INSET;
return inset + runWidth + ROW_GAP;
}
const SidebarMenuItem = forwardRef<HTMLLIElement, SidebarMenuItemProps>(
({ className, children, ...props }, ref) => {
const rowRef = useRef<HTMLLIElement>(null);
const item = useMenuRow(rowRef);
const { attachRow } = item;
// Stable ref callback: an inline one is detached and re-attached around
// every re-render, and child layout effects fire inside that null window.
const refCb = useCallback(
(node: HTMLLIElement | null) => {
attachRow(node);
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLLIElement | null>).current = node;
},
[attachRow, ref]
);
return (
<MenuItemContext.Provider value={item}>
<li
ref={refCb}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
>
{children}
</li>
</MenuItemContext.Provider>
);
}
);
SidebarMenuItem.displayName = "SidebarMenuItem";
export type SidebarMenuSubItemProps = LiHTMLAttributes<HTMLLIElement>;
const SidebarMenuSubItem = forwardRef<HTMLLIElement, SidebarMenuSubItemProps>(
({ className, children, ...props }, ref) => {
const rowRef = useRef<HTMLLIElement>(null);
const item = useMenuRow(rowRef, true);
const { attachRow } = item;
// Stable ref callback — same reason as SidebarMenuItem's.
const refCb = useCallback(
(node: HTMLLIElement | null) => {
attachRow(node);
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLLIElement | null>).current = node;
},
[attachRow, ref]
);
return (
<MenuItemContext.Provider value={item}>
<li
ref={refCb}
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
>
{children}
</li>
</MenuItemContext.Provider>
);
}
);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
// ─── Row label (ghost-span weight animation) ─────────────────────────────────
/** Splits leading string children out as the label so it can get the
* ghost-span weight treatment; remaining element children (dots, trailing
* icons) render as flex siblings after it — outside the text-box-trimmed
* span, which would clip an inline SVG, and where `ml-auto` can push a
* trailing control to the row's end. */
function MenuRowLabel({
content,
lit,
emphasized,
textClass,
}: {
content: ReactNode;
lit: boolean;
emphasized: boolean;
textClass: string;
}) {
const nodes = Children.toArray(content);
const textParts: string[] = [];
let i = 0;
while (i < nodes.length && (typeof nodes[i] === "string" || typeof nodes[i] === "number")) {
textParts.push(String(nodes[i]));
i++;
}
const label = textParts.join("");
const rest = nodes.slice(i);
if (!label) {
return (
<span
className={cn(
"flex min-w-0 flex-1 items-center gap-2 transition-colors duration-80",
lit ? "text-foreground" : "text-muted-foreground",
textClass
)}
>
{content}
</span>
);
}
return (
<>
<span className={cn("inline-grid min-w-0 text-left", textClass)}>
{/* Ghost: reserves width at the heaviest weight, hidden from AT.
Both cells truncate so a long label clips with an ellipsis
instead of wrapping the row. The trim box spans cap height to
baseline, so the overflow clip would shave ascenders and
descenders — symmetric padding extends the clip box past both
and the negative margins cancel it out of the row's height. */}
<span
className="col-start-1 row-start-1 invisible truncate pt-[0.25em] -mt-[0.25em] pb-[0.25em] -mb-[0.25em] [text-box:trim-both_cap_alphabetic]"
style={{ fontVariationSettings: fontWeights.semibold }}
aria-hidden="true"
>
{label}
</span>
{/* Visible: animates between weights in the same cell */}
<span
className={cn(
"col-start-1 row-start-1 truncate pt-[0.25em] -mt-[0.25em] pb-[0.25em] -mb-[0.25em] transition-[color,font-variation-settings] duration-80 [text-box:trim-both_cap_alphabetic]",
lit ? "text-foreground" : "text-muted-foreground"
)}
style={{
fontVariationSettings: emphasized ? fontWeights.semibold : fontWeights.normal,
}}
>
{label}
</span>
</span>
{rest}
</>
);
}
// ─── SidebarMenuButton ───────────────────────────────────────────────────────
export const sidebarMenuButtonVariants = cva(
// The trailing gutter is an exact reservation published by the row (see
// rowGutter): --row-gutter at rest, --row-gutter-hover once hover-revealed
// actions are showing. One rule per state instead of a class per
// count/badge/reveal combination.
// Disabled stays in the layout, and pointer events pass through it to the
// row, since a browser sends no mouse events to a disabled button: the
// container keeps seeing the moves, and fluid hover simply never lights it.
"peer/menu-button relative z-10 flex w-full cursor-pointer select-none items-center gap-2 pl-2 text-left outline-none disabled:opacity-50 disabled:pointer-events-none transition-[padding] duration-80 pr-[var(--row-gutter)] group-hover/menu-item:pr-[var(--row-gutter-hover)] group-focus-within/menu-item:pr-[var(--row-gutter-hover)] group-hover/menu-sub-item:pr-[var(--row-gutter-hover)] group-focus-within/menu-sub-item:pr-[var(--row-gutter-hover)] group-has-[[data-sidebar=menu-action]:is([data-state=open],[data-popup-open],[aria-expanded=true])]/menu-item:pr-[var(--row-gutter-hover)] group-has-[[data-sidebar=menu-action]:is([data-state=open],[data-popup-open],[aria-expanded=true])]/menu-sub-item:pr-[var(--row-gutter-hover)]",
{
variants: {
variant: {
default: "",
outline: "border border-border bg-background",
},
},
defaultVariants: { variant: "default" },
}
);
export interface SidebarMenuButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof sidebarMenuButtonVariants> {
isActive?: boolean;
size?: "default" | "sm" | "lg";
icon?: IconComponent;
/** Semantic thread state for status-dot navigation. Drives the dot
* visuals (`active`/`unread` → filled, `idle` → ring), stamps
* `data-status` on the button, appends visually-hidden "unread" text for
* screen readers, and `"active"` implies `isActive`. */
status?: "active" | "unread" | "idle";
/** Visual-only dot in the icon column — the escape hatch when the
* semantic `status` vocabulary doesn't fit. Overrides the dot derived
* from `status`. Ignored when `icon` is set. */
dot?: "filled" | "ring";
render?: ReactElement;
asChild?: boolean;
}
const SidebarMenuButton = forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(
(
{
isActive = false,
size = "default",
variant,
icon: Icon,
status,
dot,
render,
asChild,
className,
children,
...props
},
ref
) => {
const scope = useContext(MenuScopeContext);
const item = useContext(MenuItemContext);
const shape = useShape();
const sizeClasses = useSize();
const buttonRef = useRef<HTMLElement | null>(null);
// status="active" implies the row-active treatment; an explicit dot
// overrides the status-derived one.
const effectiveActive = isActive || status === "active";
const setActive = item?.setActive;
useIsoLayoutEffect(() => {
setActive?.(effectiveActive);
return () => setActive?.(false);
}, [effectiveActive, setActive]);
const setButtonEl = item?.setButtonEl;
useIsoLayoutEffect(() => {
setButtonEl?.(buttonRef.current);
return () => setButtonEl?.(null);
}, [setButtonEl]);
const resolvedDot =
dot ?? (status ? (status === "idle" ? "ring" : "filled") : undefined);
const lit = effectiveActive || (item?.isHovered ?? false);
const heightClass =
size === "sm"
? "h-7"
: size === "lg"
? "h-12"
: sizeClasses.variant === "compact"
? "h-7"
: "h-8";
const textClass = size === "sm" ? typeClass("body", "compact") : sizeClasses.text;
// Roving tabindex: the active rows' buttons are the menu's tab stops; with
// no active row, the menu's first row keeps it keyboard-reachable.
const row = item?.rowRef.current ?? null;
const tabIdx = effectiveActive
? 0
: scope?.hasActive
? -1
: row !== null && row === scope?.firstRowEl
? 0
: -1;
// Exact trailing reservation: at rest, hover-revealed actions claim no
// width (the label owns the row); once revealed the row widens to
// --row-gutter-hover.
const gutterHover = rowGutter(item?.actionCount ?? 0, item?.hasBadge ?? false);
const gutterRest = item?.actionsShowOnHover
? rowGutter(0, item?.hasBadge ?? false)
: gutterHover;
const gutterVars = {
"--row-gutter": `${gutterRest}px`,
"--row-gutter-hover": `${gutterHover}px`,
} as CSSProperties;
const { template, content } = resolveSlotTemplate(render, asChild, children);
const inner = (
<>
{Icon && (
<Icon
size={sizeClasses.icon}
strokeWidth={lit ? 2 : 1.5}
className={cn(
"shrink-0 transition-[color,stroke-width] duration-80",
lit ? "text-foreground" : "text-muted-foreground"
)}
/>
)}
{!Icon && resolvedDot && (
<span
className="flex shrink-0 items-center justify-center"
style={{ width: sizeClasses.icon, height: sizeClasses.icon }}
>
<span
className={cn(
"size-2 rounded-full transition-colors duration-80",
resolvedDot === "filled"
? lit
? "bg-foreground/60"
: "bg-muted-foreground/50"
: lit
? "border border-foreground/60"
: "border border-muted-foreground/50"
)}
/>
</span>
)}
<MenuRowLabel content={content} lit={lit} emphasized={effectiveActive} textClass={textClass} />
{status === "unread" && <span className="sr-only">, unread</span>}
</>
);
return (
<SlotElement
template={template}
defaultTag="button"
elementRef={(node: HTMLElement | null) => {
buttonRef.current = node;
if (typeof ref === "function") ref(node as HTMLButtonElement | null);
else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
}}
props={{
type: template ? undefined : "button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": effectiveActive ? "true" : undefined,
"data-status": status,
"aria-current": effectiveActive ? "page" : undefined,
tabIndex: tabIdx,
className: cn(
sidebarMenuButtonVariants({ variant }),
heightClass,
shape.item,
className
),
...props,
style: { ...gutterVars, ...(props.style ?? {}) },
}}
>
{inner}
</SlotElement>
);
}
);
SidebarMenuButton.displayName = "SidebarMenuButton";
// ─── SidebarMenuAction ───────────────────────────────────────────────────────
export interface SidebarMenuActionProps extends ButtonHTMLAttributes<HTMLButtonElement> {
showOnHover?: boolean;
render?: ReactElement;
asChild?: boolean;
}
const SidebarMenuAction = forwardRef<HTMLButtonElement, SidebarMenuActionProps>(
({ className, showOnHover = false, render, asChild, children, onClick, ...props }, ref) => {
const shape = useShape();
const sizeClasses = useSize();
const item = useContext(MenuItemContext);
const inCluster = useContext(MenuActionsClusterContext);
const { template, content } = resolveSlotTemplate(render, asChild, children);
// A lone action registers its own slot; inside a cluster the wrapper
// registers the whole count and each action flows in its row.
const setActions = item?.setActions;
useIsoLayoutEffect(() => {
if (inCluster || !setActions) return;
setActions(1, showOnHover);
return () => setActions(0, false);
}, [inCluster, setActions, showOnHover]);
return (
<SlotElement
template={template}
defaultTag="button"
elementRef={ref as Ref<HTMLElement>}
props={{
type: template ? undefined : "button",
"data-sidebar": "menu-action",
"data-show-on-hover": showOnHover ? "" : undefined,
className: cn(
// right-1.5 centers the 24px hit-box on the same axis as the badge
// (right-2 + min-w-5): both land 18px from the row's right edge.
// With a badge on the same row the badge keeps that rightmost spot
// and the action slides left of it. Inside a cluster the wrapper
// owns the positioning and actions simply flow.
inCluster
? "relative flex size-6 shrink-0 items-center justify-center text-muted-foreground outline-none"
: "absolute right-1.5 z-10 flex size-6 items-center justify-center text-muted-foreground outline-none",
!inCluster &&
(item?.isSubRow
? "group-has-[>[data-sidebar=menu-badge]]/menu-sub-item:right-8.5"
: "group-has-[>[data-sidebar=menu-badge]]/menu-item:right-8.5"),
!inCluster &&
(item?.isSubRow || sizeClasses.variant === "compact" ? "top-0.5" : "top-1"),
"hover:bg-hover hover:text-foreground transition-[color,background-color,opacity] duration-80",
"focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]",
// One icon size across the sidebar: row actions match the leading
// icons and the section header's actions, all on the size ladder.
// Normalize bare icons to the site's 1.5 stroke (library defaults
// vary), thickening to 2 on hover — Button's icon-only treatment.
"[&_svg]:size-[var(--icon-size)] [&_svg]:shrink-0 [&_svg]:stroke-[1.5] [&_svg]:transition-[stroke-width] [&_svg]:duration-80 hover:[&_svg]:stroke-[2]",
shape.item,
// Reveal on the OWN row only. A sub action must not use the
// menu-item group — its nearest one is the parent li, which would
// light every sibling sub action on any hover inside the sub-tree.
!inCluster &&
showOnHover &&
(item?.isSubRow
? "opacity-0 group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:opacity-100 data-[state=open]:opacity-100 aria-expanded:opacity-100"
// Tracks the row's own button (its peer), not the <li> — a row
// that hosts a sub-menu wraps its children too, and hovering a
// child should not light the parent's action.
: "opacity-0 peer-hover/menu-button:opacity-100 peer-focus-visible/menu-button:opacity-100 hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100 aria-expanded:opacity-100"),
className
),
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
// The action often sits on a row composed via `render` — keep its
// click from also triggering the row.
event.stopPropagation();
onClick?.(event);
},
...props,
style: { ...({ "--icon-size": `${sizeClasses.icon}px` } as CSSProperties), ...(props.style ?? {}) },
}}
>
{content}
</SlotElement>
);
}
);
SidebarMenuAction.displayName = "SidebarMenuAction";
// ─── SidebarMenuActions ──────────────────────────────────────────────────────
export interface SidebarMenuActionsProps extends HTMLAttributes<HTMLDivElement> {
/** Hide the cluster until the row is hovered or focused. */
showOnHover?: boolean;
}
/** Row-level cluster for more than one SidebarMenuAction. It owns the
* positioning and publishes the whole count to the row, so the button
* reserves the exact gutter the cluster occupies. */
const SidebarMenuActions = forwardRef<HTMLDivElement, SidebarMenuActionsProps>(
({ className, showOnHover = false, children, ...props }, ref) => {
const item = useContext(MenuItemContext);
const sizeClasses = useSize();
const count = Children.count(children);
const setActions = item?.setActions;
useIsoLayoutEffect(() => {
setActions?.(count, showOnHover);
return () => setActions?.(0, false);
}, [setActions, count, showOnHover]);
return (
<div
ref={ref}
data-sidebar="menu-actions"
className={cn(
"absolute right-1.5 z-10 flex items-center gap-1",
// The badge keeps the rightmost slot; the cluster sits left of it.
item?.isSubRow
? "group-has-[>[data-sidebar=menu-badge]]/menu-sub-item:right-8.5"
: "group-has-[>[data-sidebar=menu-badge]]/menu-item:right-8.5",
item?.isSubRow || sizeClasses.variant === "compact" ? "top-0.5" : "top-1",
showOnHover &&
(item?.isSubRow
? "opacity-0 transition-opacity duration-80 group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:opacity-100 has-[[data-state=open]]:opacity-100 has-[[data-popup-open]]:opacity-100"
// Peer-scoped for the same reason as a lone action: the row's
// <li> also wraps its sub-menu.
: "opacity-0 transition-opacity duration-80 peer-hover/menu-button:opacity-100 peer-focus-visible/menu-button:opacity-100 hover:opacity-100 focus-within:opacity-100 has-[[data-state=open]]:opacity-100 has-[[data-popup-open]]:opacity-100"),
className
)}
{...props}
>
<MenuActionsClusterContext.Provider value={true}>
{children}
</MenuActionsClusterContext.Provider>
</div>
);
}
);
SidebarMenuActions.displayName = "SidebarMenuActions";
// ─── SidebarMenuBadge ────────────────────────────────────────────────────────
export type SidebarMenuBadgeProps = HTMLAttributes<HTMLDivElement>;
const SidebarMenuBadge = forwardRef<HTMLDivElement, SidebarMenuBadgeProps>(
({ className, ...props }, ref) => {
const item = useContext(MenuItemContext);
const sizeClasses = useSize();
const lit = item?.isActiveRow ?? false;
const setHasBadge = item?.setHasBadge;
useIsoLayoutEffect(() => {
setHasBadge?.(true);
return () => setHasBadge?.(false);
}, [setHasBadge]);
return (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-2 z-10 flex h-5 min-w-5 items-center justify-center px-1 tabular-nums",
sizeClasses.variant === "compact" ? "top-1" : "top-1.5",
typeClass("micro", sizeClasses.variant),
"transition-[color,font-variation-settings] duration-80",
lit ? "text-foreground" : "text-muted-foreground",
className
)}
style={{
fontVariationSettings: lit ? fontWeights.semibold : fontWeights.normal,
}}
{...props}
/>
);
}
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
// ─── SidebarMenuSkeleton ─────────────────────────────────────────────────────
export interface SidebarMenuSkeletonProps extends HTMLAttributes<HTMLDivElement> {
showIcon?: boolean;
}
// Deterministic width cycle (not Math.random) so server and client render the
// same markup — a random width per render is a hydration mismatch.
const SKELETON_WIDTHS = ["62%", "74%", "55%", "82%", "68%"];
const SidebarMenuSkeleton = forwardRef<HTMLDivElement, SidebarMenuSkeletonProps>(
({ className, showIcon = false, ...props }, ref) => {
const sizeClasses = useSize();
const id = useId();
let sum = 0;
for (let i = 0; i < id.length; i++) sum += id.charCodeAt(i);
const width = SKELETON_WIDTHS[sum % SKELETON_WIDTHS.length];
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn(
"flex items-center gap-2 px-2",
sizeClasses.variant === "compact" ? "h-7" : "h-8",
className
)}
{...props}
>
{showIcon && (
<div
data-sidebar="menu-skeleton-icon"
className="size-4 shrink-0 animate-pulse rounded-md bg-hover"
/>
)}
<div
data-sidebar="menu-skeleton-text"
className="h-4 flex-1 animate-pulse rounded-md bg-hover"
style={{ maxWidth: width }}
/>
</div>
);
}
);
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
// ─── SidebarMenuSub ──────────────────────────────────────────────────────────
export interface SidebarMenuSubProps extends HTMLAttributes<HTMLUListElement> {
/** Built-in measured-height collapse. Omitted, the sub-menu is always
* visible; wire it to state (with a toggling SidebarMenuButton) for a
* collapsible tree. */
open?: boolean;
}
const SidebarMenuSub = forwardRef<HTMLUListElement, SidebarMenuSubProps>(
({ className, open = true, children, ...props }, ref) => {
const containerRef = useRef<HTMLUListElement>(null);
// The sub-menu is NOT its own highlight scope: its rows register with the
// surrounding SidebarMenu, so one hover background glides from a parent
// row into its children. Toggling flips the rows' visibility in place
// (they stay registered), so the scope re-reads what is visible.
const scope = useContext(MenuScopeContext);
const refreshVisibility = scope?.refreshVisibility;
useIsoLayoutEffect(() => {
refreshVisibility?.();
}, [open, refreshVisibility]);
// Measured-height collapse: animate between 0 and the content's real
// offsetHeight — never to "auto", which framer measures wrong under a
// scaled ancestor.
const [contentHeight, setContentHeight] = useState<number | null>(null);
useIsoLayoutEffect(() => {
const el = containerRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const measure = () => setContentHeight(el.offsetHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const measured = contentHeight !== null;
// Same rule as SidebarGroup: spring only when this sub-tree itself
// toggles. A height change coming from a nested sub collapsing inside it
// snaps, so the wrapper tracks its content instead of chasing it with a
// second spring and moving everything below late.
const [prevOpen, setPrevOpen] = useState(open);
const [toggling, setToggling] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
setToggling(true);
}
return (
<motion.div
data-slot="sidebar-menu-sub-wrapper"
// `animate` stays defined from the first render — framer ignores an
// animate prop that appears later in the element's life. Until the
// content is measured, a closed sub collapses via the h-0 class and
// an open one keeps its natural height.
className={cn("overflow-hidden", !measured && !open && "h-0")}
initial={false}
animate={
measured
? { height: open ? contentHeight : 0, opacity: open ? 1 : 0 }
: { opacity: open ? 1 : 0 }
}
// Do NOT simplify this to `open ? spring.moderate : …` — see the
// toggling note above. Springing on a re-measure stacks a second
// spring on a nested sub's own collapse.
transition={
toggling
? open
? spring.moderate
: spring.moderate.exit
: { duration: 0 }
}
onAnimationComplete={() => {
setToggling(false);
}}
>
<ul
ref={(node) => {
containerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLUListElement | null>).current = node;
}}
data-sidebar="menu-sub"
data-state={open ? "open" : "closed"}
aria-hidden={open ? undefined : true}
className={cn(
// ml-[15px] (a margin, not a translate, so the rows' measured
// rects include it) + 1px border + pl-2 lands the sub-row label
// (+ the row's own pl-2 = 32px) exactly on the parent label's x
// (px-2 + 16px icon + gap-2 = 32px).
"relative ml-[15px] flex min-w-0 flex-col border-l border-border pl-2 select-none",
className
)}
{...props}
>
{children}
</ul>
</motion.div>
);
}
);
SidebarMenuSub.displayName = "SidebarMenuSub";
// ─── SidebarMenuSubButton ────────────────────────────────────────────────────
export interface SidebarMenuSubButtonProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
isActive?: boolean;
size?: "sm" | "md";
icon?: IconComponent;
render?: ReactElement;
asChild?: boolean;
}
const SidebarMenuSubButton = forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
({ isActive = false, size = "md", icon: Icon, render, asChild, className, children, ...props }, ref) => {
const item = useContext(MenuItemContext);
const shape = useShape();
const sizeClasses = useSize();
const buttonRef = useRef<HTMLElement | null>(null);
const setActive = item?.setActive;
useIsoLayoutEffect(() => {
setActive?.(isActive);
return () => setActive?.(false);
}, [isActive, setActive]);
const setButtonEl = item?.setButtonEl;
useIsoLayoutEffect(() => {
setButtonEl?.(buttonRef.current);
return () => setButtonEl?.(null);
}, [setButtonEl]);
const lit = isActive || (item?.isHovered ?? false);
const tabIdx = isActive ? 0 : -1;
const gutterHover = rowGutter(item?.actionCount ?? 0, item?.hasBadge ?? false);
const gutterRest = item?.actionsShowOnHover
? rowGutter(0, item?.hasBadge ?? false)
: gutterHover;
const gutterVars = {
"--row-gutter": `${gutterRest}px`,
"--row-gutter-hover": `${gutterHover}px`,
} as CSSProperties;
const { template, content } = resolveSlotTemplate(render, asChild, children);
return (
<SlotElement
template={template}
defaultTag="a"
elementRef={(node: HTMLElement | null) => {
buttonRef.current = node;
if (typeof ref === "function") ref(node as HTMLAnchorElement | null);
else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
}}
props={{
"data-sidebar": "menu-sub-button",
"data-size": size,
"data-active": isActive ? "true" : undefined,
"aria-current": isActive ? "page" : undefined,
tabIndex: tabIdx,
className: cn(
"relative z-10 flex w-full cursor-pointer select-none items-center gap-2 pl-2 text-left outline-none",
"transition-[padding] duration-80 pr-[var(--row-gutter)] group-hover/menu-sub-item:pr-[var(--row-gutter-hover)] group-focus-within/menu-sub-item:pr-[var(--row-gutter-hover)] group-has-[[data-sidebar=menu-action]:is([data-state=open],[data-popup-open],[aria-expanded=true])]/menu-sub-item:pr-[var(--row-gutter-hover)]",
size === "sm" ? "h-6" : sizeClasses.variant === "compact" ? "h-6" : "h-7",
shape.item,
className
),
...props,
style: { ...gutterVars, ...(props.style ?? {}) },
}}
>
<>
{Icon && (
<Icon
size={sizeClasses.icon}
strokeWidth={lit ? 2 : 1.5}
className={cn(
"shrink-0 transition-[color,stroke-width] duration-80",
lit ? "text-foreground" : "text-muted-foreground"
)}
/>
)}
{/* Sub-rows keep the parent rows' type size — only the row height
steps down. */}
<MenuRowLabel
content={content}
lit={lit}
emphasized={isActive}
textClass={size === "sm" ? typeClass("body", "compact") : sizeClasses.text}
/>
</>
</SlotElement>
);
}
);
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuAction,
SidebarMenuActions,
SidebarMenuBadge,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
};