Icon-only vertical rail with hover tooltips, a measured indicator that slides between items, badge dots, and a bottom-pinned action cluster.
npx shadcn@latest add @paragon/side-railAlso installs: tooltip
"use client";
import * as React from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";
type SideRailIndicator = "line" | "pill";
interface SideRailContextValue {
value: string | undefined;
setValue: (value: string) => void;
indicator: SideRailIndicator;
}
const SideRailContext = React.createContext<SideRailContextValue | null>(null);
function useSideRail(component: string) {
const context = React.useContext(SideRailContext);
if (!context) {
throw new Error(`<${component}> must be used within <SideRail>`);
}
return context;
}
export interface SideRailProps
extends Omit<React.ComponentProps<"nav">, "onChange"> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** "line" slides a short bar along the left edge; "pill" slides a full backdrop. */
indicator?: SideRailIndicator;
/** Disables the sliding indicator motion. */
static?: boolean;
}
/**
* Icon-only vertical rail. Labels live in right-side tooltips (shared
* skip-delay, so scanning the rail feels instant), the active indicator is a
* single measured element that slides vertically between items — first paint
* suppressed, ResizeObserver snaps layout shifts — and ArrowUp/ArrowDown
* walk the rail. Pin settings/avatar clusters with <SideRailFooter>.
*/
export function SideRail({
value: valueProp,
defaultValue,
onValueChange,
indicator = "line",
static: isStatic = false,
className,
children,
...props
}: SideRailProps) {
const [internalValue, setInternalValue] = React.useState(defaultValue);
const value = valueProp ?? internalValue;
const navRef = React.useRef<HTMLElement>(null);
const indicatorRef = React.useRef<HTMLSpanElement>(null);
const hasPositioned = React.useRef(false);
const setValue = React.useCallback(
(next: string) => {
setInternalValue(next);
onValueChange?.(next);
},
[onValueChange],
);
const position = React.useCallback(
(animate: boolean) => {
const nav = navRef.current;
const el = indicatorRef.current;
if (!nav || !el) return;
const active = nav.querySelector<HTMLElement>('[data-active="true"]');
if (!active) {
el.style.opacity = "0";
return;
}
if (!animate) el.style.transitionProperty = "none";
el.style.opacity = "1";
if (indicator === "line") {
const height = 20;
el.style.height = `${height}px`;
el.style.width = "";
el.style.transform = `translateY(${
active.offsetTop + (active.offsetHeight - height) / 2
}px)`;
} else {
el.style.height = `${active.offsetHeight}px`;
el.style.width = `${active.offsetWidth}px`;
el.style.transform = `translate(${active.offsetLeft}px, ${active.offsetTop}px)`;
}
if (!animate) {
void el.offsetWidth;
el.style.transitionProperty = "";
}
},
[indicator],
);
React.useLayoutEffect(() => {
position(hasPositioned.current && !isStatic);
hasPositioned.current = true;
}, [position, value, isStatic]);
React.useEffect(() => {
const nav = navRef.current;
if (!nav) return;
const observer = new ResizeObserver(() => position(false));
observer.observe(nav);
return () => observer.disconnect();
}, [position]);
// ArrowUp/ArrowDown roam the rail, footer included.
const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
const nav = navRef.current;
if (!nav) return;
const items = Array.from(
nav.querySelectorAll<HTMLElement>("[data-rail-focusable]:not(:disabled)"),
);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLElement);
let next = 0;
if (event.key === "ArrowDown") next = (current + 1) % items.length;
if (event.key === "ArrowUp")
next = (current - 1 + items.length) % items.length;
if (event.key === "End") next = items.length - 1;
items[next]?.focus();
event.preventDefault();
};
const contextValue = React.useMemo(
() => ({ value, setValue, indicator }),
[value, setValue, indicator],
);
return (
<SideRailContext.Provider value={contextValue}>
<TooltipProvider>
<nav
ref={navRef}
data-slot="side-rail"
onKeyDown={onKeyDown}
className={cn(
"relative flex h-full w-13 flex-col items-center gap-1 bg-background px-2 py-2.5",
className,
)}
{...props}
>
<span
ref={indicatorRef}
aria-hidden
className={cn(
"pointer-events-none absolute top-0 opacity-0 motion-reduce:transition-none",
indicator === "line"
? "left-0 w-0.5 rounded-r-full bg-foreground [transition-property:transform,height] duration-200 ease-out"
: "left-0 rounded-lg bg-accent [transition-property:transform,width,height] duration-200 ease-out",
)}
/>
{children}
</nav>
</TooltipProvider>
</SideRailContext.Provider>
);
}
interface SideRailButtonBaseProps extends React.ComponentProps<"button"> {
/** Tooltip + accessible label. */
label: string;
/** Notification marker: `true` for a dot, a number for a count. */
badge?: boolean | number;
}
function RailBadge({ badge }: { badge: boolean | number }) {
if (badge === false) return null;
if (badge === true) {
return (
<span
aria-hidden
className="absolute top-1 right-1 size-2 rounded-full bg-destructive ring-2 ring-background"
/>
);
}
return (
<span
aria-hidden
className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none font-medium text-primary-foreground tabular-nums ring-2 ring-background"
>
{badge > 99 ? "99+" : badge}
</span>
);
}
function railItemClasses(active: boolean) {
return cn(
"relative z-10 flex size-9 shrink-0 items-center justify-center rounded-lg outline-none select-none after:absolute after:-inset-0.5",
"transition-[background-color,color,scale] duration-150 ease-out active:not-disabled:scale-[0.97]",
"focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-[18px] [&_svg]:shrink-0",
active
? "text-foreground"
: "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
);
}
export interface SideRailItemProps extends SideRailButtonBaseProps {
/** Unique value for selection. */
value: string;
}
export function SideRailItem({
value,
label,
badge = false,
className,
children,
onClick,
...props
}: SideRailItemProps) {
const rail = useSideRail("SideRailItem");
const active = rail.value === value;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="side-rail-item"
data-rail-focusable=""
data-active={active || undefined}
aria-label={label}
aria-current={active ? "page" : undefined}
onClick={(event) => {
rail.setValue(value);
onClick?.(event);
}}
className={cn(railItemClasses(active), className)}
{...props}
>
{children}
<RailBadge badge={badge} />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
{label}
{typeof badge === "number" && badge > 0 && (
<span className="text-primary-foreground/60 tabular-nums">
{" "}
· {badge}
</span>
)}
</TooltipContent>
</Tooltip>
);
}
/** Non-selectable rail action (settings, help, avatar). */
export function SideRailButton({
label,
badge = false,
className,
children,
...props
}: SideRailButtonBaseProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="side-rail-button"
data-rail-focusable=""
aria-label={label}
className={cn(railItemClasses(false), className)}
{...props}
>
{children}
<RailBadge badge={badge} />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
{label}
</TooltipContent>
</Tooltip>
);
}
export function SideRailSeparator({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
aria-hidden
data-slot="side-rail-separator"
className={cn("my-1 h-px w-6 shrink-0 bg-border", className)}
{...props}
/>
);
}
/** Bottom-pinned cluster for settings/avatar actions. */
export function SideRailFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="side-rail-footer"
className={cn("mt-auto flex flex-col items-center gap-1 pt-2", className)}
{...props}
/>
);
}