@cf/ui

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

Inbox

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

PropTypeDefault
SidebarRow activebooleanfalse
SidebarRow iconsbooleantrue
SidebarRow renderReactElement<button>
SidebarMenu itemsReactNode
SidebarMenu labelstring
SidebarStateIcon openboolean
SidebarStateIcon animatebooleantrue

Rules

The same rules ship in DESIGN.md and the MCP server, for the agents building with this.

  • must

    Mark the current row with active on SidebarRow (or sidebarRowVariants({ active })) and set aria-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 SidebarMenu once as items; 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: false to sidebarRowVariants when a row's icons size themselves, like a folder caret; the default sizes every child svg to 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/sidebar

Needs the @cf registry in your components.json once. The theme and any sibling components come along automatically.

Source

sidebar.tsxShow
"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 };