Sidebar
The macOS/iPadOS source list with accent selection.
SwiftUI: NavigationSplitView
Files
recents
Installation
npx shadcn@latest add https://cupertino-ui.baltoon.jp/r/sidebar.jsonRequires the theme tokens — see Installation if this is your first component.
Source
"use client";
import * as React from "react";
import { PanelLeftIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* NavigationSplitView sidebar — the macOS source list: translucent
* platter, section headers, accent-tinted selection, and the
* standard toolbar button that collapses and expands it with the
* split-view slide animation.
*/
type SidebarState = {
open: boolean;
setOpen: (open: boolean) => void;
selected?: string;
onSelect?: (value: string) => void;
};
const SidebarContext = React.createContext<SidebarState>({
open: true,
setOpen: () => {},
});
function useSidebar() {
return React.useContext(SidebarContext);
}
/**
* Wrap the split view (toggle + sidebar + content) in a provider so
* a SidebarToggle anywhere in the window can collapse the sidebar.
*/
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange,
selected,
onSelect,
children,
}: {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
selected?: string;
onSelect?: (value: string) => void;
children: React.ReactNode;
}) {
const [internal, setInternal] = React.useState(defaultOpen);
const open = openProp ?? internal;
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp === undefined) setInternal(next);
onOpenChange?.(next);
},
[openProp, onOpenChange]
);
return (
<SidebarContext.Provider value={{ open, setOpen, selected, onSelect }}>
{children}
</SidebarContext.Provider>
);
}
/** The macOS toolbar button with the sidebar.left symbol. */
function SidebarToggle({
className,
...props
}: React.ComponentProps<"button">) {
const { open, setOpen } = useSidebar();
return (
<button
type="button"
data-slot="sidebar-toggle"
aria-label={open ? "Hide Sidebar" : "Show Sidebar"}
aria-pressed={open}
onClick={() => setOpen(!open)}
className={cn(
"flex h-7 min-w-8 shrink-0 cursor-default select-none items-center justify-center rounded-[6px] px-1.5 text-secondary-label outline-none transition-colors duration-100 hover:bg-fill-quaternary focus-visible:ring-[3px] focus-visible:ring-blue/40 active:bg-fill-tertiary [&_svg]:size-[17px]",
className
)}
{...props}
>
<PanelLeftIcon />
</button>
);
}
function Sidebar({
className,
children,
...props
}: React.ComponentProps<"nav">) {
const { open } = React.useContext(SidebarContext);
return (
<div
data-slot="sidebar-wrapper"
data-state={open ? "open" : "collapsed"}
className={cn(
"shrink-0 self-stretch py-2 pl-2 transition-[width,padding,opacity,transform] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
open
? "w-[248px] opacity-100"
: "pointer-events-none w-0 -translate-x-2 pl-0 opacity-0"
)}
>
{/* Modern macOS sidebars float: an inset, rounded glass panel
detached from the window edge. */}
<nav
data-slot="sidebar"
aria-hidden={!open}
className={cn(
"flex h-full w-60 flex-col gap-4 overflow-y-auto overflow-x-hidden rounded-[12px] bg-secondary-background/75 p-2.5 shadow-[0_0_0_0.5px_var(--separator),0_8px_24px_rgba(0,0,0,0.08)] backdrop-blur-2xl dark:bg-gray-6/75",
className
)}
{...props}
>
<div className="flex w-[220px] shrink-0 flex-col gap-4">{children}</div>
</nav>
</div>
);
}
function SidebarSection({
className,
title,
children,
...props
}: React.ComponentProps<"div"> & { title?: string }) {
return (
<div
data-slot="sidebar-section"
className={cn("flex flex-col gap-px", className)}
{...props}
>
{title ? (
<p className="px-2.5 pb-1 text-caption-1 font-semibold text-secondary-label">
{title}
</p>
) : null}
{children}
</div>
);
}
function SidebarItem({
className,
value,
icon,
badge,
children,
...props
}: Omit<React.ComponentProps<"button">, "value"> & {
/** Identity used for selection. */
value: string;
/** Leading icon, tinted with the accent color. */
icon?: React.ReactNode;
/** Trailing count or badge. */
badge?: React.ReactNode;
}) {
const { selected, onSelect } = React.useContext(SidebarContext);
const isSelected = selected === value;
return (
<button
type="button"
data-slot="sidebar-item"
data-selected={isSelected || undefined}
onClick={() => onSelect?.(value)}
className={cn(
"flex h-8 w-full shrink-0 cursor-default select-none items-center gap-2.5 rounded-[7px] px-2.5 text-left text-subheadline text-label outline-none transition-colors duration-100 focus-visible:ring-[3px] focus-visible:ring-blue/40",
isSelected
? "bg-blue text-white [&_svg]:text-white"
: "hover:bg-fill-quaternary active:bg-fill-tertiary [&_svg]:text-blue",
"[&_svg]:size-[17px] [&_svg]:shrink-0",
className
)}
{...props}
>
{icon}
<span className="min-w-0 flex-1 truncate">{children}</span>
{badge ? (
<span
className={cn(
"shrink-0 text-footnote",
isSelected ? "text-white/80" : "text-secondary-label"
)}
>
{badge}
</span>
) : null}
</button>
);
}
export {
Sidebar,
SidebarItem,
SidebarProvider,
SidebarSection,
SidebarToggle,
useSidebar,
};