Skip to content
ssntl/ui

Side nav

Section navigation as wide as its longest label, with aria-current for the page.

Orders

32 orders this week · 4 to ship today

Installation

$ pnpm dlx shadcn@latest add @ssntl/side-nav

Usage

tsx
import { SideNav, SideNavItem } from "@/components/ui/side-nav"

<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-6">
  <SideNav aria-label="Store">
    <SideNavItem href="/orders" current><Package /> Orders</SideNavItem>
    <SideNavItem href="/customers"><Users /> Customers</SideNavItem>
  </SideNav>
  <main>…</main>
</div>
  • Content-sized: the nav is as wide as its longest label (an auto column); the content takes the rest.
  • current sets aria-current="page"; the active item also changes weight and fill, not color alone.
  • Items are 44px tall with the label and an optional 20px icon; keep labels to one or two words.

Code

components/ui/side-nav.tsx
import * as React from "react"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

// A vertical list of sections. It is as wide as its longest label, so the
// content beside it gets the room; never half the screen for three words.
// The current page is marked with aria-current, not color alone.
function SideNav({
  className,
  children,
  ...props
}: React.ComponentProps<"nav"> & { "aria-label": string }) {
  return (
    <nav
      data-slot="side-nav"
      className={cn("w-max max-w-full", className)}
      {...props}
    >
      <ul className="grid gap-1">{children}</ul>
    </nav>
  )
}

function SideNavItem({
  asChild = false,
  current = false,
  className,
  ...props
}: React.ComponentProps<"a"> & {
  /** Render the child element (a framework Link, for example). */
  asChild?: boolean
  /** The page being shown: sets aria-current="page". */
  current?: boolean
}) {
  const Comp = asChild ? Slot.Root : "a"
  return (
    <li>
      <Comp
        data-slot="side-nav-item"
        aria-current={current ? "page" : undefined}
        className={cn(
          "flex min-h-11 items-center gap-3 rounded-full px-4 text-label font-normal whitespace-nowrap text-muted-foreground",
          "transition-[background-color,color] duration-(--duration-fast) ease-out hover:bg-secondary hover:text-foreground",
          "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
          "aria-[current=page]:bg-secondary aria-[current=page]:font-medium aria-[current=page]:text-foreground",
          "[&_svg]:size-5 [&_svg]:shrink-0",
          className
        )}
        {...props}
      />
    </li>
  )
}

export { SideNav, SideNavItem }