@cf/sidebar · beta
Sidebar
The row, row menu and open/close icon the notes sidebar is built from, for any long navigable list with actions on each item.
Right-click a note, or long-press it on a phone, for its menu. From md the same menu is also behind the ⋯ that appears on hover. The icon on the top button is SidebarStateIcon.
These are the parts of the notes sidebar on connorforsyth.co that aren't about notes. The folder tree, search, drafts and drag-to-file are still in the app, built from these; I haven't pulled them out because every one of them knows what a note is. So this is a block of pieces rather than a finished sidebar you drop in, and I've marked it beta for that reason.
SidebarRow is 44px tall below md, where it's tapped, and 28px from md, where a few hundred rows have to fit. SidebarMenu wraps a row and gives it a context menu and a hover ⋯ button with the same items, and it turns off iOS's link preview so a long press only does one thing. SidebarStateIcon goes on the button that opens and closes the pane: the pane retracts into its edge while the window outline stays still. It was a framer-motion component on the site; here it's CSS transitions, so it costs nothing to install, and it respects reduced motion.
import Link from "next/link";
import { SidebarMenu, SidebarRow } from "@/components/ui/sidebar";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
<SidebarMenu items={<DropdownMenuItem>Rename</DropdownMenuItem>} label="Note actions">
<SidebarRow active aria-current="page" render={<Link href="/notes/inbox" />}>
Inbox
</SidebarRow>
</SidebarMenu>Props
| Prop | Type | Default |
|---|---|---|
| SidebarRow active | boolean | false |
| SidebarRow icons | boolean | true |
| SidebarRow render | ReactElement | <button> |
| SidebarMenu items | ReactNode | — |
| SidebarMenu label | string | — |
| SidebarStateIcon open | boolean | — |
| SidebarStateIcon animate | boolean | true |
Rules
The same rules ship in DESIGN.md and the MCP server, for the agents building with this.
- must
Mark the current row with
activeonSidebarRow(orsidebarRowVariants({ active })) and setaria-current="page"on it yourself when it's a link.row-active-state
- should
Render navigation rows as links with
render={<Link href="…" />}rather than wrapping a button in a link.links-via-render
- must
Pass a row's actions to
SidebarMenuonce asitems; it shows the same items on right-click, long press and the hover ⋯ button, so don't build a second menu.menu-items-once
- may
Pass
icons: falsetosidebarRowVariantswhen a row's icons size themselves, like a folder caret; the default sizes every childsvgto 16px and mutes it.icons-variant
- should
Leave the ⋯ button hidden below
md; on a phone the long press opens the same menu.A column of dots down a list competes with the names it's there to act on.
no-dots-on-touch
Install
bunx shadcn@latest add @cf/sidebarNeeds the @cf registry in your components.json once. The theme and any sibling components come along automatically.
Source
sidebar.tsxShowHide
"use client";
import { ContextMenu } from "@base-ui/react/context-menu";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { DotsThreeIcon } from "@phosphor-icons/react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type { ReactElement, ReactNode } from "react";
import { Button } from "./button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "./dropdown-menu";
/*
The pieces of the notes sidebar on connorforsyth.co that aren't about
notes: the row, the menu every row carries, and the icon on the button
that opens and closes the pane. The tree, search and drafts stay in the
app; they're built from these.
*/
// 44px rows on a phone, where they're tapped; 28px from md, where a list of
// a few hundred notes has to fit.
const sidebarRowVariants = cva(
"flex h-11 w-full cursor-default touch-manipulation select-none items-center gap-2 rounded-md px-2 text-base text-foreground outline-none hover:bg-foreground/5 focus-visible:ring-1 focus-visible:ring-ring md:h-7 md:text-sm",
{
defaultVariants: { active: false, icons: true },
variants: {
active: { false: "", true: "bg-foreground/8 font-medium" },
// Off for rows whose icons size themselves, like a folder's caret.
icons: {
false: "",
true: "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
},
},
}
);
/** A button by default; pass `render={<Link href="…" />}` for navigation. */
function SidebarRow({
active = false,
icons,
className,
render,
...props
}: useRender.ComponentProps<"button"> &
VariantProps<typeof sidebarRowVariants>) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarRowVariants({ active, icons }), className),
},
props
),
render,
state: { active, slot: "sidebar-row" },
});
}
/**
* The same items twice: on right-click (or a long press) anywhere on the
* row, and behind a ⋯ button that shows on hover from md. Base UI's menu
* items work in both.
*/
function SidebarMenu({
children,
items,
label,
onOpenChange,
finalFocus,
className,
}: {
children: ReactElement;
items: ReactNode;
label: string;
onOpenChange?: (open: boolean) => void;
finalFocus?: () => boolean;
className?: string;
}) {
return (
// A long press here opens the row's own context menu, so iOS's link
// preview and save sheet would be a second thing happening at once.
<div
className={cn(
"[-webkit-touch-callout:none]",
className ?? "group/row relative min-w-0 flex-1"
)}
data-slot="sidebar-menu"
>
<ContextMenu.Root onOpenChange={onOpenChange}>
<ContextMenu.Trigger render={children} />
<DropdownMenuContent
aria-label={label}
data-sidebar-menu
finalFocus={finalFocus}
side="right"
sideOffset={2}
>
{items}
</DropdownMenuContent>
</ContextMenu.Root>
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger
render={
<Button
aria-label={label}
// Not on a phone: one of these on every row is a column of dots
// down the edge of the list, competing with the names it is
// there to act on. Long-pressing the row opens the same menu;
// that is what the callout suppression above is for.
className="absolute top-0.5 right-1 hidden size-6 cursor-default data-popup-open:opacity-100 md:inline-flex md:opacity-0 md:group-hover/row:opacity-100 md:group-focus-within/row:opacity-100"
size="icon-xs"
variant="ghost"
/>
}
>
<DotsThreeIcon data-icon />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
aria-label={label}
data-sidebar-menu
finalFocus={finalFocus}
side="right"
>
{items}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/**
* The pane retracts into its edge while the outer window stays still. CSS
* transitions rather than a motion library, so it costs nothing to install;
* `animate={false}` jumps, for a state change the user didn't cause.
*/
function SidebarStateIcon({
open,
animate = true,
}: {
open: boolean;
animate?: boolean;
}) {
const transition = cn(
"transition-[opacity,transform] duration-160 ease-[cubic-bezier(0.77,0,0.175,1)] motion-reduce:transition-none",
!animate && "transition-none"
);
return (
<svg
aria-hidden="true"
className="size-4 shrink-0"
data-slot="sidebar-state-icon"
fill="none"
viewBox="0 0 20 20"
>
<rect
height="13"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
width="16"
x="2"
y="3.5"
/>
<g
className={transition}
style={{
opacity: open ? 0.18 : 0.06,
transform: open ? "scaleX(1)" : "scaleX(0.3)",
transformOrigin: "2px 10px",
}}
>
<path
d="M4 4.25H7.5V15.75H4Q2.75 15.75 2.75 14.5V5.5Q2.75 4.25 4 4.25Z"
fill="currentColor"
/>
</g>
<path
className={transition}
d="M7.5 4.25V15.75"
stroke="currentColor"
strokeWidth="1.5"
style={{
opacity: open ? 1 : 0.4,
transform: open ? "translateX(0px)" : "translateX(-3px)",
}}
/>
</svg>
);
}
export { SidebarMenu, SidebarRow, SidebarStateIcon, sidebarRowVariants };