← All components

Sidebar

A glass panel that folds into a rail of icons, with one highlight sliding between the pages.

Current page

Overview

Collapse it from the button by the wordmark.

The idea

The change between panel and rail is CSS, not measurement. Width tweens; height rides a one-row grid whose track goes from 1fr to 0fr, the one way to animate to content height without measuring it; and the body is swapped at the target width and fades up, so nothing reflows mid-tween. Collapsed, the recent list becomes a button whose flyout opens on hover intent, with an invisible bridge across the gap so the pointer can reach it.

Composes DropMenu and Tooltip.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Sidebar — usage19 lines
import { usePathname } from "next/navigation";
import { Sidebar } from "mihirmodi-ui";

const [collapsed, setCollapsed] = useState(true);
const path = usePathname();

// Give it a full-height container: expanded, it takes that height.
<aside className="h-dvh py-3 pl-3">
  <Sidebar
    collapsed={collapsed}
    onToggle={() => setCollapsed((c) => !c)}
    brand={{ label: "Acme home", to: "/", full: <Logo />, mark: <Mark /> }}
    items={PAGES}                          // { id, label, icon, to }[]
    activeId={PAGES.find((p) => path.startsWith(p.to))?.id}
    action={{ label: "New session", icon: <PlusIcon />, onClick: newSession }}
    recent={{ items: sessions.map((s) => ({ id: s.id, label: s.title, to: `/s/${s.id}` })) }}
    footer={(c) => <UserRow compact={c} />}
  />
</aside>

Props

PropNotes
collapsed / onToggleboolean / () => voidControlled. The toggle sits by the wordmark expanded and at the foot of the rail collapsed.
brand{ full, mark, label, to?, href?, onClick? }The wordmark expanded, the mark collapsed. `label` names it for both.
itemsSidebarItem[]`{ id, label, icon, to? | href? | onClick? }`. `icon` is drawn with `filled` while current or pointed at.
activeIdstringWhich item is the page you are on.
action{ label, icon, onClick }One primary action under the pages.
recent{ items, activeId?, title?, all? }A list when expanded, a flyout beside the rail when collapsed.
footer(collapsed) => ReactNodeThe bottom of the panel — a user row. Told which form to draw.
width / railWidthnumberThe two widths it tweens between.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/Sidebar.tsx398 lines
"use client";

import { useEffect, useLayoutEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { DropMenu } from "./DropMenu.js";
import { Tooltip } from "./Tooltip.js";
import { useLinkComponent, type LinkComponent } from "../lib/link.js";
import { glyphProps, type Glyph } from "../lib/glyph.js";
import { FLAT, SLIDE } from "../lib/motion.js";

export type SidebarItem = {
  id: string;
  label: string;
  /** Drawn with `filled` true while the item is current or pointed at. */
  icon: ComponentType<Glyph>;
  to?: string;
  href?: string;
  onClick?: () => void;
};

export type SidebarRecentItem = { id: string; label: string; meta?: string; to?: string; href?: string; onClick?: () => void };

export type SidebarProps = {
  collapsed: boolean;
  onToggle: () => void;
  /** The wordmark when expanded, the mark when collapsed, and where it goes. */
  brand: { full: ReactNode; mark: ReactNode; label: string; to?: string; href?: string; onClick?: () => void };
  items: SidebarItem[];
  activeId?: string;
  /** One primary action under the pages — "New session". */
  action?: { label: string; icon: ReactNode; onClick: () => void };
  /**
   * A list of recent things. Expanded, it is a list under the action; collapsed,
   * it becomes one button that opens it as a flyout beside the rail.
   */
  recent?: { title?: string; items: SidebarRecentItem[]; activeId?: string; all?: { label: string; to?: string; href?: string } };
  /** The bottom of the panel — a user row. Told whether the rail is collapsed. */
  footer?: (collapsed: boolean) => ReactNode;
  width?: number;
  railWidth?: number;
  label?: string;
  link?: LinkComponent;
};

/* Hover intent for the flyout: a beat before it opens so a pointer passing
   through the rail does not flash it, and a beat before it closes so the
   pointer can cross the gutter into the panel. */
const OPEN_AFTER = 90;
const CLOSE_AFTER = 160;

function Target({
  to,
  href,
  onClick,
  link,
  children,
  ...rest
}: {
  to?: string;
  href?: string;
  onClick?: () => void;
  link?: LinkComponent;
  children: ReactNode;
  className?: string;
  "aria-label"?: string;
  "aria-current"?: "page";
  onMouseEnter?: () => void;
  onFocus?: () => void;
}) {
  const Link = useLinkComponent(link);
  if (to) return <Link href={to} onClick={onClick} {...rest}>{children}</Link>;
  if (href) return <a href={href} target="_blank" rel="noopener" onClick={onClick} {...rest}>{children}</a>;
  return <button type="button" onClick={onClick} {...rest}>{children}</button>;
}

/**
 * Floating navigation for an app. Expanded, a glass panel the height of its
 * container; collapsed, a vertical rail of icons, content-height and centred.
 *
 * The change between the two is CSS, not measurement. The panel's width
 * tweens, and its height rides a one-row grid whose track goes from `1fr` (the
 * full height) to `0fr` (the rail's content) — the one way to animate to
 * content height without measuring it. The body inside is swapped at the target
 * width and fades up, so nothing reflows mid-tween.
 *
 * One highlight slides between the pages, measured from each row's offset, and
 * each page's icon fills while it is current or pointed at — the same rule the
 * Dock follows, so fill means "this one" everywhere.
 *
 * Put it in a full-height container: it takes that height when expanded.
 */
export function Sidebar({
  collapsed,
  onToggle,
  brand,
  items,
  activeId,
  action,
  recent,
  footer,
  width = 264,
  railWidth = 60,
  label = "Primary",
  link,
}: SidebarProps) {
  const reduce = useReducedMotion();
  const listRef = useRef<HTMLUListElement>(null);
  const [hl, setHl] = useState<{ y: number; h: number } | null>(null);
  const [hovered, setHovered] = useState<number | null>(null);
  const activeIdx = items.findIndex((p) => p.id === activeId);
  const targetIdx = hovered ?? activeIdx;

  useLayoutEffect(() => {
    const el = listRef.current?.querySelectorAll<HTMLElement>(":scope > li")[targetIdx];
    setHl(el ? { y: el.offsetTop, h: el.offsetHeight } : null);
  }, [targetIdx, collapsed, items.length]);

  const w = collapsed ? railWidth : width;
  const iconBtn = "flex h-10 w-10 items-center justify-center rounded-[12px]";

  return (
    <LazyMotion features={domAnimation} strict>
      <div
        className="grid h-full content-center transition-[grid-template-rows] duration-[260ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
        style={{ gridTemplateRows: collapsed ? "0fr" : "1fr" }}
      >
        <nav
          aria-label={label}
          // A grid item's minimum height is its content by default. Expanded, the
          // panel drops it, so it is exactly the container's height and the
          // recent list scrolls; collapsed, it keeps it, because the content
          // height is exactly what the `0fr` track is animating to.
          className={`glass-panel dock-shadow relative flex flex-col rounded-[14px] border-[0.5px] border-gray-alpha-400 transition-[width] duration-[260ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none ${
            collapsed ? "py-2" : "min-h-0"
          }`}
          // Expanded, the wide body is clipped while the panel grows into it.
          // Collapsed, it overflows, so the flyouts beside the rail can show.
          style={{ width: w, overflowX: collapsed ? "visible" : "clip" }}
        >
          <div key={collapsed ? "rail" : "panel"} className="sidebar-body flex min-h-0 flex-1 flex-col" style={{ width: w }}>
            {/* Brand, and the collapse control while expanded. */}
            <div className={`flex items-center ${collapsed ? "h-11 justify-center" : "h-14 justify-between pl-5 pr-3"}`}>
              {collapsed ? (
                <Tooltip label={brand.label} side="right">
                  <Target to={brand.to} href={brand.href} onClick={brand.onClick} link={link} aria-label={brand.label} className={`${iconBtn} text-gray-1000`}>
                    {brand.mark}
                  </Target>
                </Tooltip>
              ) : (
                <>
                  <Target to={brand.to} href={brand.href} onClick={brand.onClick} link={link} aria-label={brand.label} className="flex items-center text-gray-1000">
                    {brand.full}
                  </Target>
                  <Tooltip label="Collapse" side="bottom">
                    <button
                      type="button"
                      onClick={onToggle}
                      aria-label="Collapse navigation"
                      aria-expanded
                      className="flex h-8 w-8 items-center justify-center rounded-[10px] text-quiet transition-colors duration-150 ease-geist hover:bg-gray-alpha-200 hover:text-gray-1000"
                    >
                      <PanelIcon />
                    </button>
                  </Tooltip>
                </>
              )}
            </div>

            {collapsed && <Rule />}

            {/* The pages, with one highlight between them. */}
            <ul
              ref={listRef}
              className={`relative isolate flex flex-col gap-1 ${collapsed ? "px-2" : "mt-1 px-3"}`}
              onMouseLeave={() => setHovered(null)}
            >
              <m.span
                aria-hidden
                className="pointer-events-none absolute -z-10 rounded-[12px] bg-highlight shadow-[0_1px_2px_rgba(0,0,0,0.05)]"
                style={{ left: collapsed ? 8 : 12, right: collapsed ? 8 : 12 }}
                initial={false}
                animate={hl ? { y: hl.y, height: hl.h, opacity: 1 } : { opacity: 0 }}
                transition={reduce ? FLAT : { ...SLIDE, opacity: { duration: 0.16, ease: "easeOut" } }}
              />
              {items.map((p, i) => {
                const active = p.id === activeId;
                const chosen = active || hovered === i;
                const row = (
                  <Target
                    to={p.to}
                    href={p.href}
                    onClick={p.onClick}
                    link={link}
                    aria-current={active ? "page" : undefined}
                    aria-label={collapsed ? p.label : undefined}
                    onMouseEnter={() => setHovered(i)}
                    onFocus={() => setHovered(i)}
                    className={`flex items-center gap-3.5 text-[14px] transition-colors duration-150 ease-geist ${
                      active ? "font-medium text-gray-1000" : "text-gray-900 hover:text-gray-1000"
                    } ${collapsed ? `${iconBtn} justify-center` : "h-10 w-full rounded-[10px] px-3 text-left"}`}
                  >
                    <span className={`transition-colors duration-150 ${chosen ? "text-gray-1000" : "text-quiet"}`}>
                      <p.icon filled={chosen} />
                    </span>
                    {!collapsed && <span className="truncate">{p.label}</span>}
                  </Target>
                );
                return (
                  <li key={p.id}>
                    {collapsed ? (
                      <Tooltip label={p.label} side="right">
                        {row}
                      </Tooltip>
                    ) : (
                      row
                    )}
                  </li>
                );
              })}
            </ul>

            {collapsed && (action || recent) && <Rule />}

            {/* The action, and the recent list or its flyout. */}
            {(action || recent) && (
              <div className={collapsed ? "flex flex-col items-center gap-1 px-2" : "mt-5 px-3"}>
                {action &&
                  (collapsed ? (
                    <Tooltip label={action.label} side="right">
                      <button
                        type="button"
                        onClick={action.onClick}
                        aria-label={action.label}
                        className={`${iconBtn} border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 transition-[background-color,transform] duration-150 ease-geist hover:bg-gray-100 active:scale-[0.94]`}
                      >
                        {action.icon}
                      </button>
                    </Tooltip>
                  ) : (
                    <button
                      type="button"
                      onClick={action.onClick}
                      className="flex h-10 w-full items-center gap-3.5 rounded-[10px] border-[0.5px] border-gray-alpha-400 bg-background-100 px-3 text-[14px] text-gray-1000 transition-[background-color,transform] duration-150 ease-geist hover:bg-gray-100 active:scale-[0.98]"
                    >
                      <span className="text-quiet">{action.icon}</span>
                      {action.label}
                    </button>
                  ))}
                {recent && collapsed && <RecentFlyout recent={recent} link={link} />}
              </div>
            )}

            {recent && !collapsed && (
              <div className="mt-7 min-h-0 flex-1 overflow-y-auto px-3 pb-2">
                <div className="mb-2 flex items-center justify-between px-3">
                  <span className="text-[11px] uppercase tracking-[0.06em] text-faint">{recent.title ?? "Recent"}</span>
                  {recent.all && (
                    <Target to={recent.all.to} href={recent.all.href} link={link} className="text-[11px] text-quiet transition-colors hover:text-gray-1000">
                      {recent.all.label}
                    </Target>
                  )}
                </div>
                <RecentList recent={recent} link={link} />
              </div>
            )}
            {!recent && !collapsed && <div className="flex-1" />}

            {footer && (
              <>
                {collapsed && <Rule />}
                <div className={collapsed ? "flex flex-col items-center gap-1 px-2" : "border-t-[0.5px] border-gray-alpha-400 px-3 py-3"}>
                  {collapsed && (
                    <Tooltip label="Expand" side="right">
                      <button
                        type="button"
                        onClick={onToggle}
                        aria-label="Expand navigation"
                        aria-expanded={false}
                        className={`${iconBtn} text-quiet transition-colors duration-150 ease-geist hover:text-gray-1000`}
                      >
                        <PanelIcon />
                      </button>
                    </Tooltip>
                  )}
                  {footer(collapsed)}
                </div>
              </>
            )}
            {!footer && collapsed && (
              <div className="flex justify-center px-2 pt-1">
                <Tooltip label="Expand" side="right">
                  <button type="button" onClick={onToggle} aria-label="Expand navigation" aria-expanded={false} className={`${iconBtn} text-quiet hover:text-gray-1000`}>
                    <PanelIcon />
                  </button>
                </Tooltip>
              </div>
            )}
          </div>
        </nav>
      </div>
    </LazyMotion>
  );
}

function Rule() {
  return <span aria-hidden className="mx-auto my-1 h-[0.5px] w-5 bg-gray-alpha-300" />;
}

function RecentList({ recent, link, onPick }: { recent: NonNullable<SidebarProps["recent"]>; link?: LinkComponent; onPick?: () => void }) {
  if (recent.items.length === 0) return <p className="px-3 py-2 text-[13px] text-faint">Nothing yet.</p>;
  return (
    <ul className="flex flex-col gap-px">
      {recent.items.map((s) => {
        const current = s.id === recent.activeId;
        return (
          <li key={s.id}>
            <Target
              to={s.to}
              href={s.href}
              onClick={() => {
                s.onClick?.();
                onPick?.();
              }}
              link={link}
              aria-current={current ? "page" : undefined}
              className={`flex h-9 w-full items-center gap-3 rounded-[8px] px-3 text-left text-[13px] transition-colors duration-150 ${
                current ? "bg-gray-alpha-200 font-medium text-gray-1000" : "text-gray-900 hover:bg-gray-alpha-200 hover:text-gray-1000"
              }`}
            >
              <span className="min-w-0 flex-1 truncate">{s.label}</span>
              {s.meta && <span className="shrink-0 text-[11px] text-faint">{s.meta}</span>}
            </Target>
          </li>
        );
      })}
    </ul>
  );
}

/**
 * The collapsed rail's recent list: one button that opens the list in a glass
 * panel beside the rail, on hover intent. An invisible bridge spans the gap so
 * the pointer can cross into the panel without it closing behind it. Keyboard
 * and touch open it with a press.
 */
function RecentFlyout({ recent, link }: { recent: NonNullable<SidebarProps["recent"]>; link?: LinkComponent }) {
  const [open, setOpen] = useState(false);
  const timer = useRef<number | undefined>(undefined);
  const arm = (next: boolean, after: number) => {
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setOpen(next), after);
  };
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return (
    <div className="relative" onMouseEnter={() => arm(true, OPEN_AFTER)} onMouseLeave={() => arm(false, CLOSE_AFTER)}>
      <button
        type="button"
        aria-label={recent.title ?? "Recent"}
        aria-haspopup="true"
        aria-expanded={open}
        onClick={() => setOpen((o) => !o)}
        className={`flex h-10 w-10 items-center justify-center rounded-[12px] transition-colors duration-150 ${
          open ? "bg-gray-alpha-200 text-gray-1000" : "text-quiet hover:text-gray-1000"
        }`}
      >
        <HistoryIcon />
      </button>
      {open && <span aria-hidden className="absolute left-full top-0 h-full w-3" />}
      <DropMenu open={open} placement="down" origin="origin-top-left" className="absolute left-full top-0 z-50 ml-3">
        <div className="glass-panel dock-shadow w-[260px] rounded-[14px] border-[0.5px] border-gray-alpha-400 p-1.5">
          <p className="px-2.5 pb-1.5 pt-1.5 text-[11px] uppercase tracking-[0.06em] text-faint">{recent.title ?? "Recent"}</p>
          <div className="-mx-0.5">
            <RecentList recent={recent} link={link} onPick={() => setOpen(false)} />
          </div>
        </div>
      </DropMenu>
    </div>
  );
}

function PanelIcon() {
  return (
    <svg {...glyphProps} className="h-[15px] w-[15px]">
      <rect x="3.5" y="4.5" width="17" height="15" rx="3" />
      <path d="M9.5 4.5v15" />
    </svg>
  );
}

function HistoryIcon() {
  return (
    <svg {...glyphProps} className="h-[17px] w-[17px]">
      <path d="M4 12a8 8 0 1 0 2.4-5.7M4 4v4.5h4.5" />
      <path d="M12 8v4l2.5 2" />
    </svg>
  );
}
packages/ui/src/components/Tooltip.tsx52 lines
"use client";

import { useState, type ReactNode } from "react";

export type TooltipProps = {
  label: ReactNode;
  children: ReactNode;
  side?: "top" | "right" | "bottom" | "left";
  className?: string;
};

/**
 * A name for an icon-only control, in an inverted chip beside it.
 *
 * Shown on hover and on focus, so the keyboard gets the same answer as the
 * pointer, and it is `role="tooltip"` with nothing interactive inside — a
 * tooltip is a label, not a place to put a button. It fades rather than
 * travels: it is appearing beside something you are already looking at, and
 * movement there only competes with the thing it is naming.
 *
 * For a row of icons that share one tooltip sliding between them, use `Dock`,
 * which owns that as one piece of state.
 */
export function Tooltip({ label, children, side = "top", className = "" }: TooltipProps) {
  const [on, setOn] = useState(false);
  const pos = {
    right: "left-full top-1/2 -translate-y-1/2 ml-2",
    left: "right-full top-1/2 -translate-y-1/2 mr-2",
    top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
    bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
  }[side];

  return (
    <span
      className={`relative inline-flex ${className}`}
      onMouseEnter={() => setOn(true)}
      onMouseLeave={() => setOn(false)}
      onFocus={() => setOn(true)}
      onBlur={() => setOn(false)}
    >
      {children}
      <span
        role="tooltip"
        className={`pointer-events-none absolute z-20 inline-flex h-[22px] items-center whitespace-nowrap rounded-md bg-ink px-2 text-[11px] font-medium leading-none text-background-100 shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)] transition-opacity duration-200 ease-out motion-reduce:transition-none ${pos} ${
          on ? "opacity-100" : "opacity-0"
        }`}
      >
        {label}
      </span>
    </span>
  );
}