Astro UI
Sidebar Nav.
Collapsible workspace and chat navigation with searchable history.
NavigationComponent1 dependency Beautiful UI, MIT
Install
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import SidebarNav from "../components/viewership/sidebar-nav/SidebarNav.astro"; --- <SidebarNav />
Props 12
| Prop | Type | Default | Description |
|---|---|---|---|
workspace | { key, name, monogram? } | { key: "workspace", name: "Workspace" } | Current workspace. |
workspaces | SidebarWorkspace[] | · | Switcher menu workspaces. Defaults to the current one. |
menuActions | { key, label, icon? }[] | New workspace / Workspace settings / Invite team members | Workspace menu actions. Sign out is always last. |
items | { key, label, icon?, count?, href? }[] | Home / Invite users 3/10 | Primary rows. `icon`: built-in name (home, invite, inbox, calendar, chart, book, settings, spark) or inner SVG. `href` makes a link. |
recents | { id, label, href? }[] | [] | Searchable chat history. |
recentsLabel | string | "Chats" | History label. |
activeNav / activeRecent | string | · | Current item. Gets aria-current="page". |
newChat | boolean | true | Show the New chat row. |
footerLabel / footerHref | string | null | "Upgrade" | Footer button. null hides it. |
collapsed | boolean | false | Start as the 52px rail. |
fill / height | boolean / string | false / "600px" | Fill parent height, or fixed height. |
Events | CustomEvent | · | vs-sidebar-navigate { key }, vs-sidebar-pick { id, label }, vs-sidebar-new-chat, vs-sidebar-toggle { collapsed }, vs-sidebar-workspace { key }, vs-sidebar-menu { action }, vs-sidebar-footer. |
Source 1 file
---
/**
* SidebarNav: collapsible workspace and chat navigation with a gliding hover
* state and searchable history. Collapsing keeps every rail glyph centred
* at x = 26 in both states.
*
* Ported from Beautiful UI `components/primitives/SidebarNav.tsx`
* (MIT, © 2026 Shane Levine). Icons are Lucide (ISC).
*
* - Collapse and expand buttons use `aria-expanded`; the current item
* carries `aria-current="page"`.
* - Items with `href` render as links; without, as buttons that fire an event.
* - Without JS the expanded sidebar renders with working links.
*
* Events (bubbling, on the root):
* vs-sidebar-navigate detail: { key }
* vs-sidebar-pick detail: { id, label }
* vs-sidebar-new-chat
* vs-sidebar-toggle detail: { collapsed }
* vs-sidebar-workspace detail: { key }
* vs-sidebar-menu detail: { action }
* vs-sidebar-footer
*
* @example
* <SidebarNav workspace={{ key: "northwind", name: "Northwind Outdoor" }} recents={chats} />
*/
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";
export interface SidebarWorkspace {
key: string;
name: string;
/** Single glyph for the menu's monogram. Defaults to the first letter. */
monogram?: string;
}
export interface SidebarItem {
key: string;
label: string;
/** A built-in icon name (home, invite, inbox, calendar, chart, book, settings, spark) or raw inner SVG markup (24×24, stroked). */
icon?: string;
/** Trailing meta, e.g. "3/10". */
count?: string;
href?: string;
}
export interface SidebarRecent {
id: string;
label: string;
href?: string;
}
type Props = HTMLAttributes<"nav"> & {
/** Current workspace. */
workspace?: SidebarWorkspace;
/** Every workspace listed in the switcher (defaults to just the current one). */
workspaces?: SidebarWorkspace[];
/** Workspace menu actions below the workspace list. */
menuActions?: Array<{ key: string; label: string; icon?: string }>;
items?: SidebarItem[];
/** Label of the history section. */
recentsLabel?: string;
recents?: SidebarRecent[];
/** Key of the current primary item (aria-current). */
activeNav?: string;
/** Id of the current chat (aria-current). */
activeRecent?: string;
/** Show the New chat row. */
newChat?: boolean;
footerLabel?: string | null;
footerHref?: string;
/** Start collapsed. */
collapsed?: boolean;
/** Fill the parent's height instead of `height`. */
fill?: boolean;
height?: string;
};
const {
workspace = { key: "workspace", name: "Workspace" },
workspaces,
menuActions = [
{ key: "new-workspace", label: "New workspace", icon: "plus" },
{ key: "settings", label: "Workspace settings", icon: "settings" },
{ key: "invite", label: "Invite team members", icon: "invite" },
],
items = [
{ key: "home", label: "Home", icon: "home" },
{ key: "invite", label: "Invite users", icon: "invite", count: "3/10" },
],
recentsLabel = "Chats",
recents = [],
activeNav,
activeRecent,
newChat = true,
footerLabel = "Upgrade",
footerHref,
collapsed = false,
fill = false,
height = "600px",
class: className,
style,
"aria-label": ariaLabel = "Workspace navigation",
...rest
} = Astro.props;
const uid = `vs-sb-${Math.random().toString(36).slice(2, 8)}`;
const allWorkspaces = workspaces?.length ? workspaces : [workspace];
/* Lucide icons (ISC). */
const ICONS: Record<string, string> = {
home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" /><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />',
invite: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6" /><path d="M22 11h-6" />',
edit: '<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />',
search: '<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />',
x: '<path d="M18 6 6 18" /><path d="m6 6 12 12" />',
chevronDown: '<path d="m6 9 6 6 6-6" />',
panelClose: '<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m16 15-3-3 3-3" />',
panelOpen: '<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m14 9 3 3-3 3" />',
plus: '<path d="M5 12h14" /><path d="M12 5v14" />',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" />',
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" />',
check: '<path d="M20 6 9 17l-5-5" />',
spark: '<path d="M12 3l1.8 7.2L21 12l-7.2 1.8L12 21l-1.8-7.2L3 12l7.2-1.8z" />',
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />',
calendar: '<path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" />',
chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" />',
book: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />',
};
const icon = (name: string | undefined, size = 18, sw = 2) => {
const inner = !name ? ICONS.home : name.includes("<") ? name : (ICONS[name] ?? ICONS.home);
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
};
const rootStyle = [fill ? "--sb-h: 100%" : `--sb-h: ${height}`, typeof style === "string" ? style : ""].filter(Boolean).join("; ");
---
<nav
class:list={["vs-sidebar-nav", className]}
id={uid}
data-collapsed={collapsed ? "true" : "false"}
aria-label={ariaLabel}
style={rootStyle}
{...rest}
>
<div class="sb-tree">
<div class="sb-top">
<button
type="button"
class="sb-workspace"
data-workspace-trigger
aria-haspopup="menu"
aria-expanded="false"
aria-controls={`${uid}-ws-menu`}
inert={collapsed}
>
<span class="sb-logo" set:html={icon("spark")} />
<span class="sb-copy sb-ws-name">{workspace.name}</span>
<span class="sb-copy sb-ws-chevron" set:html={icon("chevronDown", 16)} />
<span class="sb-sr">, switch workspace</span>
</button>
<button type="button" class="sb-collapse" data-collapse aria-label="Collapse sidebar" aria-expanded="true" aria-controls={uid} inert={collapsed}>
<Fragment set:html={icon("panelClose")} />
</button>
<button type="button" class="sb-expand" data-expand aria-label="Expand sidebar" aria-expanded="false" aria-controls={uid} inert={!collapsed}>
<Fragment set:html={icon("panelOpen")} />
</button>
</div>
<GlideMenu class="sb-group" rowSelector="[data-row]" highlightBg="var(--vs-hover-2)" highlightRadius="7px" highlightInset="8px" highlightClass="sb-highlight">
{
newChat && (
<button type="button" class="sb-row" data-row data-new-chat title={collapsed ? "New chat" : undefined}>
<span class="sb-icon" set:html={icon("edit")} />
<span class="sb-copy sb-label">New chat</span>
</button>
)
}
{
items.map((item) => {
const current = item.key === activeNav;
const Tag = item.href ? "a" : "button";
return (
<Tag
class="sb-row"
data-row
data-nav={item.key}
href={item.href}
type={item.href ? undefined : "button"}
aria-current={current ? "page" : undefined}
>
<span class="sb-icon" set:html={icon(item.icon)} />
<span class="sb-copy sb-label">{item.label}</span>
{item.count && <span class="sb-copy sb-count">{item.count}</span>}
</Tag>
);
})
}
</GlideMenu>
<div class="sb-chats" data-chats inert={collapsed}>
<div class="sb-copy sb-chats-head">
<div class="sb-chats-label" id={`${uid}-chats-label`}>
<Fragment set:html={icon("chevronDown", 16)} />
<span>{recentsLabel}</span>
</div>
<button type="button" class="sb-search-btn" data-search-open aria-label={`Search ${recentsLabel.toLowerCase()}`} aria-expanded="false" aria-controls={`${uid}-search`}>
<Fragment set:html={icon("search", 16)} />
</button>
<div class="sb-search" id={`${uid}-search`} data-search>
<span class="sb-search-icon" set:html={icon("search", 15)} />
<input type="search" class="sb-search-input" data-search-input placeholder={`Search ${recentsLabel.toLowerCase()}`} aria-label={`Search ${recentsLabel.toLowerCase()} history`} tabindex="-1" />
<button type="button" class="sb-search-close" data-search-close aria-label="Close search" tabindex="-1">
<Fragment set:html={icon("x", 16)} />
</button>
</div>
</div>
<GlideMenu class="sb-group sb-recents" rowSelector="[data-row]" highlightBg="var(--vs-hover-2)" highlightRadius="7px" highlightInset="8px" highlightClass="sb-highlight" role="list" aria-labelledby={`${uid}-chats-label`}>
{
recents.map((item) => {
const current = item.id === activeRecent;
const Tag = item.href ? "a" : "button";
return (
<div role="listitem" class="sb-li" data-recent-item>
<Tag
class="sb-row sb-row--recent"
data-row
data-recent={item.id}
href={item.href}
type={item.href ? undefined : "button"}
title={item.label}
aria-current={current ? "page" : undefined}
>
<span class="sb-copy sb-label">{item.label}</span>
</Tag>
</div>
);
})
}
</GlideMenu>
<p class="sb-copy sb-empty" data-empty hidden>No {recentsLabel.toLowerCase()} found</p>
<p class="sb-sr" aria-live="polite" data-search-status></p>
</div>
{
footerLabel && (
<div class="sb-copy sb-footer" data-footer inert={collapsed}>
{footerHref ? (
<a class="sb-footer-btn" href={footerHref}>{footerLabel}</a>
) : (
<button type="button" class="sb-footer-btn" data-footer-btn>
{footerLabel}
</button>
)}
</div>
)
}
</div>
<div class="sb-menu" id={`${uid}-ws-menu`} popover="manual" data-ws-menu>
<GlideMenu class="sb-menu-list" role="menu" aria-label="Workspace" arrowKeys highlightBg="var(--vs-hover-2)" highlightRadius="8px">
{
allWorkspaces.map((ws) => (
<button
type="button"
class="sb-menu-row sb-menu-row--ws"
data-menu-row
role="menuitemradio"
aria-checked={ws.key === workspace.key ? "true" : "false"}
data-ws={ws.key}
>
<span class="sb-monogram" aria-hidden="true">{ws.monogram ?? ws.name.charAt(0)}</span>
<span class="sb-menu-label sb-menu-label--strong">{ws.name}</span>
<span class="sb-menu-check" set:html={icon("check", 18)} />
</button>
))
}
<div class="sb-menu-sep" role="separator"></div>
{
menuActions.map((a) => (
<button type="button" class="sb-menu-row" data-menu-row role="menuitem" data-action={a.key}>
<span class="sb-menu-icon" set:html={icon(a.icon, 16)} />
<span class="sb-menu-label">{a.label}</span>
</button>
))
}
<div class="sb-menu-sep" role="separator"></div>
<button type="button" class="sb-menu-row" data-menu-row role="menuitem" data-action="sign-out">
<span class="sb-menu-icon" set:html={icon("logout", 16)} />
<span class="sb-menu-label">Sign out</span>
</button>
</GlideMenu>
</div>
</nav>
<script>
function init(root: HTMLElement) {
const emit = (name: string, detail?: unknown) => root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
const q = <T extends HTMLElement = HTMLElement>(sel: string) => root.querySelector<T>(sel);
const wsTrigger = q<HTMLButtonElement>("[data-workspace-trigger]")!;
const collapseBtn = q<HTMLButtonElement>("[data-collapse]")!;
const expandBtn = q<HTMLButtonElement>("[data-expand]")!;
const chats = q("[data-chats]")!;
const footer = q("[data-footer]");
const menu = q("[data-ws-menu]")!;
const searchOpenBtn = q<HTMLButtonElement>("[data-search-open]")!;
const searchInput = q<HTMLInputElement>("[data-search-input]")!;
const searchClose = q<HTMLButtonElement>("[data-search-close]")!;
const empty = q("[data-empty]")!;
const status = q("[data-search-status]")!;
const newChatBtn = q<HTMLButtonElement>("[data-new-chat]");
/* ── collapse ──────────────────────────────────────── */
const navRows = Array.from(root.querySelectorAll<HTMLElement>("[data-nav], [data-new-chat]"));
const setCollapsed = (collapsed: boolean, moveFocus: boolean) => {
root.dataset.collapsed = String(collapsed);
collapseBtn.inert = collapsed;
wsTrigger.inert = collapsed;
chats.inert = collapsed;
if (footer) footer.inert = collapsed;
expandBtn.inert = !collapsed;
collapseBtn.setAttribute("aria-expanded", String(!collapsed));
expandBtn.setAttribute("aria-expanded", String(!collapsed));
// Icon-only rows keep a tooltip while collapsed.
navRows.forEach((row) => {
if (collapsed) row.title = row.querySelector(".sb-label")?.textContent ?? "";
else row.removeAttribute("title");
});
if (collapsed) {
closeMenu(false);
closeSearch(false);
}
if (moveFocus) (collapsed ? expandBtn : collapseBtn).focus();
emit("vs-sidebar-toggle", { collapsed });
};
collapseBtn.addEventListener("click", () => setCollapsed(true, true));
expandBtn.addEventListener("click", () => setCollapsed(false, true));
/* ── current item ──────────────────────────────────── */
const setCurrent = (row: HTMLElement | null) => {
root.querySelectorAll("[data-row][aria-current]").forEach((el) => el.removeAttribute("aria-current"));
row?.setAttribute("aria-current", "page");
};
root.querySelectorAll<HTMLElement>("[data-nav]").forEach((row) => {
row.addEventListener("click", () => {
if (row.tagName === "BUTTON") setCurrent(row);
emit("vs-sidebar-navigate", { key: row.dataset.nav });
});
});
root.querySelectorAll<HTMLElement>("[data-recent]").forEach((row) => {
row.addEventListener("click", () => {
if (row.tagName === "BUTTON") setCurrent(row);
emit("vs-sidebar-pick", { id: row.dataset.recent, label: row.textContent?.trim() });
});
});
newChatBtn?.addEventListener("click", () => {
setCurrent(null);
emit("vs-sidebar-new-chat");
});
root.querySelector("[data-footer-btn]")?.addEventListener("click", () => emit("vs-sidebar-footer"));
/* ── workspace menu (top layer) ────────────────────── */
const supportsPopover = typeof HTMLElement.prototype.showPopover === "function";
let menuOpen = false;
const openMenu = (viaKeyboard: boolean) => {
menuOpen = true;
wsTrigger.setAttribute("aria-expanded", "true");
menu.classList.add("is-open");
if (supportsPopover) menu.showPopover();
positionMenu();
menu.classList.remove("is-anim");
void menu.offsetWidth;
menu.classList.add("is-anim");
if (viaKeyboard) menu.querySelector<HTMLElement>('[aria-checked="true"]')?.focus();
};
function positionMenu() {
const rect = wsTrigger.getBoundingClientRect();
const width = menu.offsetWidth;
menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`;
menu.style.top = `${rect.bottom + 6}px`;
}
function closeMenu(restoreFocus: boolean) {
if (!menuOpen) return;
menuOpen = false;
wsTrigger.setAttribute("aria-expanded", "false");
menu.classList.remove("is-open", "is-anim");
if (supportsPopover && menu.matches(":popover-open")) menu.hidePopover();
if (restoreFocus) wsTrigger.focus();
}
wsTrigger.addEventListener("click", (e) => (menuOpen ? closeMenu(false) : openMenu(e.detail === 0)));
wsTrigger.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" && !menuOpen) {
e.preventDefault();
openMenu(true);
}
});
menu.addEventListener("click", (e) => {
const row = (e.target as Element).closest<HTMLElement>("[data-menu-row]");
if (!row) return;
if (row.dataset.ws) {
menu.querySelectorAll("[data-ws]").forEach((el) => el.setAttribute("aria-checked", String(el === row)));
const name = row.querySelector(".sb-menu-label")?.textContent ?? "";
const label = root.querySelector(".sb-ws-name");
if (label) label.textContent = name;
emit("vs-sidebar-workspace", { key: row.dataset.ws });
} else emit("vs-sidebar-menu", { action: row.dataset.action });
closeMenu(true);
});
menu.addEventListener("keydown", (e) => {
if (e.key === "Escape" || e.key === "Tab") {
if (e.key === "Escape") e.preventDefault();
closeMenu(e.key === "Escape");
}
});
const onPointerDown = (e: PointerEvent) => {
const t = e.target as Element;
if (menuOpen && !t.closest("[data-ws-menu]") && !wsTrigger.contains(t)) closeMenu(false);
};
const onResize = () => menuOpen && positionMenu();
document.addEventListener("pointerdown", onPointerDown);
window.addEventListener("resize", onResize);
window.addEventListener("scroll", onResize, { passive: true });
/* ── chat search: the field grows right → left from the search control ── */
const recentItems = Array.from(root.querySelectorAll<HTMLElement>("[data-recent-item]"));
const filter = () => {
const query = searchInput.value.trim().toLowerCase();
let shown = 0;
recentItems.forEach((item) => {
const match = (item.textContent ?? "").toLowerCase().includes(query);
item.hidden = !match;
if (match) shown += 1;
});
empty.hidden = !(query && shown === 0);
status.textContent = query ? (shown ? `${shown} ${shown === 1 ? "result" : "results"}` : empty.textContent ?? "") : "";
};
const openSearch = () => {
root.dataset.search = "open";
searchOpenBtn.setAttribute("aria-expanded", "true");
searchOpenBtn.tabIndex = -1;
searchInput.tabIndex = 0;
searchClose.tabIndex = 0;
searchInput.focus();
};
function closeSearch(restoreFocus: boolean) {
if (root.dataset.search !== "open") return;
delete root.dataset.search;
searchOpenBtn.setAttribute("aria-expanded", "false");
searchOpenBtn.tabIndex = 0;
searchInput.tabIndex = -1;
searchClose.tabIndex = -1;
searchInput.value = "";
filter();
if (restoreFocus) searchOpenBtn.focus();
}
searchOpenBtn.addEventListener("click", openSearch);
searchClose.addEventListener("click", () => closeSearch(true));
searchInput.addEventListener("input", filter);
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
closeSearch(true);
}
});
return () => {
closeMenu(false);
document.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("resize", onResize);
window.removeEventListener("scroll", onResize);
};
}
const teardowns = new Set<() => void>();
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-sidebar-nav:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
teardowns.add(init(root));
});
boot();
document.addEventListener("astro:page-load", boot);
document.addEventListener("astro:before-swap", () => {
teardowns.forEach((fn) => fn());
teardowns.clear();
});
</script>
<style>
.vs-sidebar-nav {
--sb-expanded: 224px;
--sb-collapsed: 52px;
--sb-duration: 280ms;
--sb-copy-duration: 180ms;
--sb-copy-offset: 8px;
--sb-search-duration: 180ms;
--sb-easing: var(--vs-ease-link);
position: relative;
display: flex;
flex-shrink: 0;
width: var(--sb-expanded);
height: var(--sb-h, 600px);
overflow: hidden;
color: var(--vs-ink);
font-family: var(--vs-font-sans);
text-align: left;
transition: width var(--sb-duration) var(--sb-easing);
}
.vs-sidebar-nav[data-collapsed="true"] {
width: var(--sb-collapsed);
}
.sb-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
button,
a {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
text-decoration: none;
cursor: pointer;
}
button:focus-visible,
a:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
/* one persistent 224px tree, clipped by the shell */
.sb-tree {
display: flex;
width: var(--sb-expanded);
min-height: 0;
flex-shrink: 0;
flex-direction: column;
}
.sb-copy {
opacity: 1;
transform: translateX(0);
transition:
opacity var(--sb-copy-duration) ease-out,
transform var(--sb-copy-duration) var(--sb-easing);
}
[data-collapsed="true"] .sb-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sb-copy-offset) * -1));
}
/* ── header ─────────────────────────────────────────── */
.sb-top {
position: relative;
height: 40px;
margin-bottom: 10px;
flex-shrink: 0;
}
.sb-workspace {
position: absolute;
top: 4px;
left: 8px;
display: flex;
width: 164px;
height: 32px;
align-items: center;
padding: 0 8px;
border-radius: 8px;
text-align: left;
transition: background-color 100ms ease-out, transform 100ms ease-out;
}
.sb-workspace:hover,
.sb-workspace[aria-expanded="true"] {
background: var(--vs-hover-2);
}
.sb-workspace:active {
transform: scale(0.99);
}
.sb-logo {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--vs-ink);
transition: opacity var(--sb-copy-duration) ease-out;
}
.sb-ws-name {
min-width: 0;
flex: 1;
margin-left: 6px;
overflow: hidden;
color: var(--vs-ink-2);
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-ws-chevron {
display: flex;
flex-shrink: 0;
margin-left: 4px;
color: var(--vs-ink-3);
}
.sb-collapse,
.sb-expand {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--vs-ink-3);
transition:
opacity var(--sb-copy-duration) ease-out,
background-color 150ms ease-out,
color 150ms ease-out;
}
.sb-collapse {
top: 4px;
right: 8px;
width: 32px;
height: 32px;
}
.sb-expand {
top: 2px;
left: 8px;
width: 36px;
height: 36px;
opacity: 0;
pointer-events: none;
}
.sb-collapse:hover,
.sb-expand:hover {
background: var(--vs-hover-2);
color: var(--vs-ink);
}
[data-collapsed="true"] .sb-workspace {
pointer-events: none;
}
[data-collapsed="true"] .sb-logo,
[data-collapsed="true"] .sb-collapse {
pointer-events: none;
opacity: 0;
}
[data-collapsed="true"] .sb-expand {
pointer-events: auto;
opacity: 1;
}
/* ── rows ───────────────────────────────────────────── */
.sb-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.sb-row {
position: relative;
display: flex;
width: 208px;
height: 32px;
margin: 0 8px;
align-items: center;
padding: 0 8px;
border-radius: 8px;
color: var(--vs-ink-2);
text-align: left;
transition:
width var(--sb-copy-duration) var(--sb-easing),
background-color 150ms ease-out,
color 150ms ease-out,
transform 150ms ease-out;
}
[data-collapsed="true"] .sb-row {
width: 36px;
}
.sb-row:active {
transform: scale(0.98);
}
.sb-row[aria-current="page"] {
background: var(--vs-hover-2);
color: var(--vs-ink);
}
/* The gliding highlight stands in for the current row's fill while it is out. */
.sb-group[data-glide-active] .sb-row[aria-current="page"] {
background: transparent;
}
.sb-row:focus-visible {
box-shadow: inset 0 0 0 2px var(--vs-accent);
}
.sb-icon {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.sb-label {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-icon + .sb-label {
margin-left: 6px;
}
.sb-count {
flex-shrink: 0;
margin-right: 8px;
color: var(--vs-ink-3);
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.vs-sidebar-nav :global(.sb-highlight) {
transition: width var(--sb-copy-duration) var(--sb-easing);
}
[data-collapsed="true"] :global(.sb-highlight) {
right: auto;
width: 36px;
}
/* ── chats ──────────────────────────────────────────── */
.sb-chats {
min-height: 0;
flex: 1;
margin-top: 12px;
overflow-y: auto;
}
.sb-chats-head {
position: relative;
height: 32px;
margin: 0 8px 4px;
}
.sb-chats-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
color: var(--vs-ink-3);
font-size: 12.5px;
font-weight: 500;
transition:
opacity var(--sb-search-duration) var(--sb-easing),
transform var(--sb-search-duration) var(--sb-easing);
}
.sb-search-btn {
position: absolute;
z-index: 1;
top: 0;
right: 0;
display: flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--vs-ink-3);
transition:
opacity var(--sb-search-duration) ease-out,
background-color 150ms ease-out,
color 150ms ease-out,
transform 150ms ease-out;
}
.sb-search-btn:hover {
background: var(--vs-hover-2);
color: var(--vs-ink);
}
.sb-search-btn:active {
transform: scale(0.96);
}
.sb-search {
position: absolute;
z-index: 2;
top: 0;
right: 0;
display: flex;
width: 28px;
height: 32px;
align-items: center;
overflow: hidden;
border-radius: 8px;
background: var(--vs-field);
box-shadow: var(--vs-shadow-hairline);
color: var(--vs-ink-3);
opacity: 0;
pointer-events: none;
transition:
width var(--sb-search-duration) var(--sb-easing),
opacity var(--sb-search-duration) var(--sb-easing);
}
.sb-search:focus-within {
color: var(--vs-ink-2);
}
[data-search="open"] .sb-search {
width: 100%;
opacity: 1;
pointer-events: auto;
}
[data-search="open"] .sb-chats-label {
pointer-events: none;
opacity: 0;
transform: translateX(-4px);
}
[data-search="open"] .sb-search-btn {
pointer-events: none;
opacity: 0;
}
.sb-search-icon {
display: flex;
flex-shrink: 0;
margin-left: 8px;
}
.sb-search-input {
min-width: 0;
flex: 1;
margin-left: 6px;
border: 0;
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 13px;
font-weight: 500;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.sb-search-input::placeholder {
color: var(--vs-ink-3);
}
.sb-search-input::-webkit-search-cancel-button {
display: none;
}
.sb-search-close {
display: flex;
width: 32px;
height: 32px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--vs-ink-3);
transition: background-color 150ms ease-out, color 150ms ease-out, transform 150ms ease-out;
}
.sb-search-close:hover {
background: var(--vs-hover-2);
color: var(--vs-ink);
}
.sb-search-close:focus-visible {
box-shadow: inset 0 0 0 2px var(--vs-accent);
}
.sb-empty {
margin: 0 8px;
padding: 8px;
color: var(--vs-ink-3);
font-size: 12.5px;
}
/* ── footer ─────────────────────────────────────────── */
.sb-footer {
width: 208px;
margin: 12px 8px 0;
padding-top: 12px;
border-top: 1px solid var(--vs-line);
}
.sb-footer-btn {
display: flex;
width: 100%;
height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: var(--vs-radius-control);
background: var(--vs-hover-2);
color: var(--vs-ink);
font-size: 12.5px;
font-weight: 500;
transition: background-color 150ms ease-out, transform 150ms ease-out;
}
.sb-footer-btn:hover {
background: var(--vs-line-strong);
}
.sb-footer-btn:active {
transform: scale(0.98);
}
/* ── workspace menu ─────────────────────────────────── */
.sb-menu {
position: fixed;
inset: auto;
z-index: 50;
display: none;
width: 256px;
margin: 0;
padding: 6px;
border: 0;
border-radius: 14px;
overflow: visible;
background: var(--vs-surface);
color: var(--vs-ink);
box-shadow: var(--vs-shadow-overlay);
font-family: var(--vs-font-sans);
text-align: left;
transform-origin: top left;
}
.sb-menu.is-open {
display: block;
}
.sb-menu.is-anim {
animation: vs-pop-in 180ms var(--vs-ease-out) both;
}
.sb-menu-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.sb-menu-row {
display: flex;
width: 100%;
height: 36px;
align-items: center;
gap: 6px;
padding: 0 8px;
border-radius: 8px;
text-align: left;
}
.sb-menu-row--ws {
height: 40px;
}
.sb-menu-row:focus-visible {
box-shadow: none;
}
.sb-monogram {
display: flex;
width: 24px;
height: 24px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 7px;
background: var(--vs-ink);
color: var(--vs-surface);
font-size: 11px;
font-weight: 600;
}
.sb-menu-icon {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--vs-ink-2);
}
.sb-menu-label {
min-width: 0;
flex: 1;
overflow: hidden;
color: var(--vs-ink);
font-size: 13.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-menu-label--strong {
font-weight: 500;
}
.sb-menu-check {
display: flex;
flex-shrink: 0;
color: var(--vs-ink);
visibility: hidden;
}
.sb-menu-row[aria-checked="true"] .sb-menu-check {
visibility: visible;
}
.sb-menu-sep {
height: 1px;
margin: 4px 0;
background: var(--vs-line);
}
@media (prefers-reduced-motion: reduce) {
.vs-sidebar-nav,
.sb-copy,
.sb-row,
.sb-search,
.sb-chats-label,
.vs-sidebar-nav :global(.sb-highlight) {
transition: none;
}
.sb-menu.is-anim {
animation: none;
}
}
@media (forced-colors: active) {
.sb-row[aria-current="page"] {
outline: 1px solid Highlight;
}
.sb-menu {
outline: 1px solid CanvasText;
}
}
</style> Ported from Beautiful UI, MIT. Notices.