Skip to content

Sidebar Channels

Chat-app style double sidebar: a column of gradient workspace squircles plus a glass channel panel with collapsible sections, private/live icons, unread counts and LIVE meeting badges; drawer on mobile.

glass/layout/sidebar-channels
Open ↗

Source

"use client";

import { useEffect, useId, useState, type ReactNode } from "react";
import { ChevronDown, Hash, Lock, Menu, Plus, Video, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SidebarChannelsWorkspace {
  name: string;
  initials: string;
  tone: string;
}

export interface SidebarChannelsSection {
  title: string;
  items: { label: string; href: string; private?: boolean; unread?: number; live?: boolean }[];
}

export interface SidebarChannelsProps {
  workspaces?: SidebarChannelsWorkspace[];
  activeWorkspace?: number;
  sections?: SidebarChannelsSection[];
  activeHref?: string;
  children?: ReactNode;
  className?: string;
}

const GLASS = "da-stroke bg-da-surface backdrop-blur-da backdrop-saturate-150";

const DEFAULT_SECTIONS: SidebarChannelsSection[] = [
  {
    title: "Recap channels",
    items: [
      { label: "product", href: "#product", unread: 3 },
      { label: "design-crit", href: "#design" },
      { label: "sales-calls", href: "#sales", unread: 12 },
      { label: "leadership", href: "#leadership", private: true },
    ],
  },
  {
    title: "Live now",
    items: [
      { label: "Customer call — Parcel", href: "#live-parcel", live: true },
      { label: "Standup · Mobile", href: "#live-standup", live: true },
    ],
  },
];

/** Two-column chat-app sidebar: a workspace switcher column of gradient squircles plus a glass channel list with sections, unread counts and live meetings; drawer on mobile. */
export function SidebarChannels({
  workspaces = [
    { name: "Lumen", initials: "Lu", tone: "from-da-primary to-da-accent" },
    { name: "Parcel", initials: "Pa", tone: "from-da-danger to-da-primary" },
    { name: "Side project", initials: "Sp", tone: "from-da-accent to-da-success" },
  ],
  activeWorkspace = 0,
  sections = DEFAULT_SECTIONS,
  activeHref = "#product",
  children,
  className,
}: SidebarChannelsProps) {
  const [open, setOpen] = useState(false);
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
  const id = useId();

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  const ws = workspaces[activeWorkspace] ?? workspaces[0];

  return (
    <div className={cn("flex min-h-dvh text-da-fg", className)}>
      {open && <div aria-hidden className="fixed inset-0 z-40 bg-da-overlay lg:hidden" onClick={() => setOpen(false)} />}
      <aside
        id={id}
        className={cn(
          "fixed inset-y-0 left-0 z-50 flex p-3 transition-transform duration-(--da-duration-slow) ease-da lg:sticky lg:top-0 lg:h-dvh lg:translate-x-0 motion-reduce:transition-none",
          open ? "translate-x-0" : "-translate-x-full",
        )}
      >
        <nav aria-label="Workspaces" className="flex flex-col items-center gap-2 pr-3">
          {workspaces.map((w, i) => (
            <a
              key={w.name}
              href={`#ws-${i}`}
              aria-label={w.name}
              aria-current={i === activeWorkspace ? "true" : undefined}
              className={cn(
                "da-focus da-transition grid size-11 place-items-center rounded-da-md bg-gradient-to-br font-da-display text-sm font-semibold text-da-primary-fg shadow-da-sm hover:rounded-da-sm",
                w.tone,
                i === activeWorkspace && "ring-2 ring-da-fg ring-offset-2 ring-offset-da-bg",
              )}
            >
              {w.initials}
            </a>
          ))}
          <button type="button" aria-label="Add workspace" className={cn("da-focus grid size-11 place-items-center rounded-da-md text-da-muted-fg", GLASS)}>
            <Plus aria-hidden className="size-5" />
          </button>
        </nav>
        <div className={cn("flex w-60 flex-col rounded-da-lg p-3 shadow-da-lg", GLASS)}>
          <div className="flex items-center justify-between px-2 py-1.5">
            <p className="font-da-display font-semibold">{ws?.name}</p>
            <button
              type="button"
              onClick={() => setOpen(false)}
              aria-label="Close navigation"
              className="da-focus grid size-7 place-items-center rounded-full lg:hidden"
            >
              <X aria-hidden className="size-4" />
            </button>
          </div>
          <nav aria-label="Channels" className="mt-3 flex-1 space-y-4 overflow-y-auto">
            {sections.map((s) => {
              const isCollapsed = collapsed[s.title];
              return (
                <div key={s.title}>
                  <button
                    type="button"
                    aria-expanded={!isCollapsed}
                    onClick={() => setCollapsed((c) => ({ ...c, [s.title]: !c[s.title] }))}
                    className="da-focus flex w-full items-center gap-1 rounded-da-sm px-2 py-1 text-xs font-semibold text-da-muted-fg hover:text-da-fg"
                  >
                    <ChevronDown aria-hidden className={cn("da-transition size-3.5", isCollapsed && "-rotate-90")} />
                    {s.title}
                  </button>
                  <ul hidden={isCollapsed} className="mt-1 space-y-0.5">
                    {s.items.map((it) => {
                      const active = it.href === activeHref;
                      const Icon = it.live ? Video : it.private ? Lock : Hash;
                      return (
                        <li key={it.href}>
                          <a
                            href={it.href}
                            aria-current={active ? "page" : undefined}
                            className={cn(
                              "da-focus da-transition flex items-center gap-2 rounded-da-sm px-2 py-1.5 text-sm",
                              active ? "bg-gradient-to-r from-da-primary to-da-accent font-semibold text-da-primary-fg" : "hover:bg-da-surface",
                              !active && it.unread && "font-semibold",
                            )}
                          >
                            <Icon aria-hidden className={cn("size-4 shrink-0", !active && (it.live ? "text-da-danger" : "text-da-muted-fg"))} />
                            <span className="truncate">{it.label}</span>
                            {it.live && <span className="ml-auto rounded-da-pill bg-da-danger px-1.5 text-[10px] font-semibold text-da-danger-fg">LIVE</span>}
                            {it.unread !== undefined && (
                              <span
                                className={cn(
                                  "ml-auto rounded-da-pill px-1.5 text-[11px] font-semibold",
                                  active ? "bg-da-primary-fg/25" : "bg-da-primary text-da-primary-fg",
                                )}
                              >
                                {it.unread}
                                <span className="sr-only"> unread</span>
                              </span>
                            )}
                          </a>
                        </li>
                      );
                    })}
                  </ul>
                </div>
              );
            })}
          </nav>
        </div>
      </aside>
      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2 px-4 pt-4 lg:hidden">
          <button
            type="button"
            onClick={() => setOpen(true)}
            aria-expanded={open}
            aria-controls={id}
            aria-label="Open navigation"
            className={cn("da-focus grid size-10 place-items-center rounded-full", GLASS)}
          >
            <Menu aria-hidden className="size-5" />
          </button>
        </div>
        <main>{children}</main>
      </div>
    </div>
  );
}

export default SidebarChannels;

modules/glass/layout/sidebar-channels/index.tsx

Props

PropTypeDefaultDescription
workspaces{ name, initials, tone }[]—Switcher (tone = gradient classes).
activeWorkspacenumber0Index.
sections{ title, items: { label, href, private?, unread?, live? }[] }[]—Channel groups.
activeHrefstring—Current channel.
childrenReactNode—Content.
classNamestring—Classes.

Other sidebar variants in Glass

Sidebar in other art directions

App Sidebar

App shell with a sticky left sidebar: workspace header, grouped nav with yellow active state and counters, usage meter, user footer. Becomes a top bar + slide-over drawer below lg.

BrutalistSidebar

Icon Rail

Compact 80px icon rail: ink brand square, square icon links with yellow active state, red counters and ink tooltips on hover/focus, settings pinned at the bottom. Server-safe, CSS-only tooltips.

BrutalistSidebar

Sidebar Collapsible

App sidebar that collapses from 256px to a 76px icon column with a stepped width animation; ink active item, labels become sr-only when collapsed, collapse toggle pinned at the bottom.

BrutalistSidebar

Sidebar Dual

Two-level navigation: an ink icon rail switches sections, and a paper second panel lists that section's links with counts and a yellow active link. Second panel hides on very small screens.

BrutalistSidebar

App Sidebar

Linear-style app shell: 240px translucent sidebar with workspace switcher, search, "New" button with shortcut, grouped 32px nav rows with counters, team list with color squares and settings at the bottom. Slide-in drawer below lg.

MinimalSidebar

Sidebar Inset

Inset app shell: the sidebar sits on the tinted page background while content lives in a raised rounded panel. Workspace header, collapsible nested sections with guide line, support/settings and user card; drawer on mobile.

MinimalSidebar

Sidebar Rail

Sidebar that collapses between a 224px labelled list and a 56px icon rail (⌘B or bottom button) with tooltips on hover/focus and corner badges; icon-only on mobile. Controlled or uncontrolled.

MinimalSidebar

Sidebar Tree

Docs / settings sidebar with a filter box and a nested collapsible tree auto-expanded to the active page (highlighted in indigo); stacks above content on mobile.

MinimalSidebar

App Sidebar

Typographic app sidebar: hairline right border, logo + site switcher, mono group labels, plain text links with right-aligned mono counts; the active link is ink with a signal dot; drawer on mobile.

Mono CleanSidebar

Sidebar Icons

Narrow icon rail (56px) of square hairline cells; the active cell is filled ink; labels appear as mono tooltips to the right on hover/focus; turns into a bottom bar on mobile.

Mono CleanSidebar

Sidebar Ink

Ink sidebar (stays near-black in dark mode): giant numbered text links “01 Overview”, the active one full white with an arrow, a storage meter as a hairline bar at the bottom; drawer on mobile.

Mono CleanSidebar

Sidebar Tree

CMS page tree: nested pages drawn with hairline guide lines and +/− toggles (aria-expanded), mono “Pages” header with an add button; active page ink with signal dot; drawer on mobile.

Mono CleanSidebar

App Sidebar

Classic enterprise app shell: white bordered sidebar with brand, entity switcher, grouped nav (uppercase group labels, count badges, blue active state), settings and user footer; off-canvas drawer on mobile.

Neo CorporateSidebar

Sidebar Collapsible

Sidebar that collapses from 240px to a 64px icon rail with a toggle button (aria-expanded); collapsed items keep tooltips (title) and sr-only labels. Always a rail on small screens.

Neo CorporateSidebar

Sidebar Dual

Two-level navigation: a narrow icon rail of top-level sections (tooltips, aria-pressed) and a secondary panel listing the selected section’s pages; the panel hides below md.

Neo CorporateSidebar

Sidebar Navy

Dark navy sidebar (fg-colored in light mode, surface in dark mode — dark in both): brand, search field, nav with white-tint active state and badges, and a cash-balance card at the bottom; drawer on mobile.

Neo CorporateSidebar

App Sidebar

Calm app shell: cream sidebar with leaf logo, pill nav items (sage pill for the active page), a small “this year” footprint card with a sprout, and settings at the bottom; drawer on mobile.

Organic SoftSidebar

Sidebar Floating

Floating sidebar: a rounded cream panel inset from the page edges with a soft shadow, italic serif group labels, a sage “New entry” pill and sage-tinted active items; drawer on mobile.

Organic SoftSidebar

Sidebar Garden

Deep sage sidebar (dark sage tint in dark mode) with cream text, a translucent active pill, count bubbles, rolling hills illustrated along the bottom and the user’s name on the hill; drawer on mobile.

Organic SoftSidebar

Sidebar Rail

Slim round icon rail: circular icon links on a sand column, the active one a sage circle with a leaf-shaped marker; labels appear as soft tooltips on hover/focus; becomes a bottom tab bar on mobile.

Organic SoftSidebar

App Sidebar

Friendly app shell: warm sidebar with emoji workspace chip, big periwinkle “New task” pill, search field, nav with peach count bubbles and a colored-dot board list; slide-in drawer on mobile.

Soft FlatSidebar

Sidebar Boards

Board navigator: starred favorites, then collapsible emoji spaces holding emoji boards with count bubbles; active board in a white pill; dashed “New space” button.

Soft FlatSidebar

Sidebar Card

Floating white sidebar card with generous radius on a tinted page: two-dot logo, nav with lavender active pill, butter “tip of the day” card and user row; horizontal nav on mobile.

Soft FlatSidebar

Sidebar Rail

Slim rounded icon rail: round icons whose active item fills with its own pastel color, dark pill tooltips, emoji avatar at the bottom; floating bottom bar on mobile.

Soft FlatSidebar