App & Dashboardappdashboardsaassidebarnavigationshell
App Sidebar
SaaS sidebar with workspace switcher, grouped nav with icons and badges, collapsible icon rail and plan card.
Live Preview
Usage
Copy and paste this code into your React component:
tsx
import { AppSidebar } from 'react-component-kit-ui/app';
<AppSidebar currentHref="/app/" groups={[{ items: [{ label: 'Dashboard', href: '/app/', icon: 'dashboard' }, { label: 'Reservations', href: '/app/reservations/', icon: 'calendar', badge: 4 }] }]} />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/AppSidebar.tsx + AppSidebar.css:
AppSidebar.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { appIcon } from './app-icons';
import './AppSidebar.css';
/** class:list โ className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/** "a: b; --c: d" โ style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
interface Item { label: string; href: string; icon?: string; badge?: string | number }
interface Group { label?: string; items: Item[] }
/**
* AppSidebar โ SaaS app navigation: workspace switcher, grouped nav items with icons and
* count badges, collapsible to an icon rail, and an upgrade/plan card at the bottom.
*
* @param {{name: string, plan?: string, initials?: string}} workspace - Workspace shown at the top.
* @param {Array<{label?: string, items: {label: string, href: string, icon?: string, badge?: string|number}[]}>} groups - Nav groups.
* @param {string} currentHref - Marks the matching item with aria-current.
* @param {{title: string, text?: string, href: string, cta?: string, progress?: number}} promo - Card at the bottom (optional).
* @param {boolean} fullHeight - Full-viewport sticky sidebar (app shell). false renders it inline (docs, cards). Default true.
* @param {boolean} collapsible - Show the collapse toggle (remembered in localStorage). Default true.
* @param {string} variant - Style (surface|transparent|accent). Default 'surface'.
* @param {string} size - Width preset (sm|md|lg). Default 'md'.
* @param {string} id - Element id (AppTopbar's menu button opens it on mobile). Default 'app-sidebar'.
* @param {string} className - Extra class.
*
* Accessibility: a labelled nav landmark; the current page uses aria-current; when collapsed,
* labels stay available to screen readers and appear as tooltips on hover/focus.
*/
export interface AppSidebarProps {
workspace?: { name: string; plan?: string; initials?: string };
groups?: Group[];
currentHref?: string;
promo?: { title: string; text?: string; href: string; cta?: string; progress?: number };
fullHeight?: boolean;
collapsible?: boolean;
variant?: 'surface' | 'transparent' | 'accent';
size?: 'sm' | 'md' | 'lg';
id?: string;
className?: string;
}
export function AppSidebar({
workspace = { name: 'Hotel Mirador', plan: 'Pro plan', initials: 'HM' },
groups = [
{ items: [
{ label: 'Dashboard', href: '#', icon: 'dashboard' },
{ label: 'Reservations', href: '#reservations', icon: 'calendar', badge: 12 },
{ label: 'Rooms', href: '#rooms', icon: 'bed' },
{ label: 'Guests', href: '#guests', icon: 'users' },
] },
{ label: 'Insights', items: [
{ label: 'Reports', href: '#reports', icon: 'chart' },
{ label: 'Revenue', href: '#revenue', icon: 'wallet' },
] },
{ label: 'Account', items: [{ label: 'Settings', href: '#settings', icon: 'settings' }] },
],
currentHref = '#',
promo = { title: 'Channel manager', text: '3 of 5 channels connected', href: '#', cta: 'Connect more', progress: 60 },
fullHeight = true,
collapsible = true,
variant = 'surface',
size = 'md',
id = 'app-sidebar',
className = '',
}: AppSidebarProps) {
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
document.querySelectorAll<HTMLElement>('[data-app-sidebar]:not([data-bound])').forEach((sb) => {
sb.dataset.bound = '';
const btn = sb.querySelector<HTMLButtonElement>('[data-collapse]');
const key = `${sb.id}:collapsed`;
const set = (on: boolean) => {
sb.classList.toggle('is-collapsed', on);
btn?.setAttribute('aria-pressed', String(on));
btn?.setAttribute('aria-label', on ? 'Expand sidebar' : 'Collapse sidebar');
};
try { set(window.localStorage.getItem(key) === '1'); } catch { /* ignore */ }
btn?.addEventListener('click', () => {
const on = !sb.classList.contains('is-collapsed');
set(on);
try { window.localStorage.setItem(key, on ? '1' : '0'); } catch { /* ignore */ }
});
// Mobile drawer: opened by any [data-sidebar-open="<id>"] button (AppTopbar's menu button).
const backdrop = sb.nextElementSibling as HTMLElement | null;
const open = (v: boolean) => {
sb.classList.toggle('is-open', v);
if (backdrop) backdrop.hidden = !v;
document.querySelectorAll(`[data-sidebar-open="${sb.id}"]`).forEach((b) => b.setAttribute('aria-expanded', String(v)));
if (v) sb.querySelector<HTMLElement>('a, button')?.focus();
};
document.querySelectorAll<HTMLElement>(`[data-sidebar-open="${sb.id}"]`).forEach((b) => b.addEventListener('click', () => open(!sb.classList.contains('is-open'))));
backdrop?.addEventListener('click', () => open(false));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && sb.classList.contains('is-open')) open(false); });
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<aside className={cx(['app-sb', `app-sb--${variant}`, `app-sb--${size}`, { 'app-sb--inline': !fullHeight }, className])} id={id} data-app-sidebar aria-label="Application" data-rk-ad73bd="">
<div className="app-sb__top" data-rk-ad73bd="">
<a className="app-sb__ws" href={groups[0]?.items[0]?.href ?? '#'} data-rk-ad73bd="">
<span className="app-sb__ws-logo" aria-hidden="true" data-rk-ad73bd="">{workspace.initials ?? workspace.name.slice(0, 2).toUpperCase()}</span>
<span className="app-sb__ws-text app-sb__label" data-rk-ad73bd="">
<strong data-rk-ad73bd="">{workspace.name}</strong>
{workspace.plan && <small data-rk-ad73bd="">{workspace.plan}</small>}
</span>
</a>
{collapsible && (
<button className="app-sb__collapse" type="button" aria-label="Collapse sidebar" aria-pressed="false" data-collapse data-rk-ad73bd="">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-ad73bd=""><rect x="3" y="3" width="18" height="18" rx="2" data-rk-ad73bd="" /><path d="M9 3v18" data-rk-ad73bd="" /></svg>
</button>
)}
</div>
<nav className="app-sb__nav" aria-label="Main" data-rk-ad73bd="">
{groups.map((g, __i: number) => (
<div key={__i} className="app-sb__group" data-rk-ad73bd="">
{g.label && <p className="app-sb__group-label app-sb__label" data-rk-ad73bd="">{g.label}</p>}
<ul data-rk-ad73bd="">
{g.items.map((item) => {
const active = item.href === currentHref;
return (
<li data-rk-ad73bd="">
<a className={cx(['app-sb__link', { 'is-active': active }])} href={item.href} aria-current={active ? 'page' : undefined} data-tip={item.label} data-rk-ad73bd="">
<span className="app-sb__icon" aria-hidden="true" data-rk-ad73bd=""><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" data-rk-ad73bd=""><g dangerouslySetInnerHTML={{ __html: appIcon(item.icon) }} data-rk-ad73bd="" /></svg></span>
<span className="app-sb__text app-sb__label" data-rk-ad73bd="">{item.label}</span>
{item.badge !== undefined && <span className="app-sb__badge app-sb__label" data-rk-ad73bd="">{item.badge}</span>}
</a>
</li>
);
})}
</ul>
</div>
))}
</nav>
{promo && (
<a className="app-sb__promo app-sb__label" href={promo.href} data-rk-ad73bd="">
<strong data-rk-ad73bd="">{promo.title}</strong>
{promo.text && <span data-rk-ad73bd="">{promo.text}</span>}
{promo.progress !== undefined && <span className="app-sb__meter" aria-hidden="true" data-rk-ad73bd=""><i style={st(`width: ${promo.progress}%`)} data-rk-ad73bd=""></i></span>}
{promo.cta && <em data-rk-ad73bd="">{promo.cta} โ</em>}
</a>
)}
</aside>
<div className="app-sb-backdrop" data-sidebar-backdrop hidden data-rk-ad73bd=""></div>
</>
);
}
export default AppSidebar;AppSidebar.css
/* AppSidebar โ ported from astro-component-kit (app/AppSidebar.astro) */
.app-sb:where([data-rk-ad73bd]) {
--sb-w: 252px;
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: var(--sp-4, 1rem);
width: var(--sb-w);
height: 100dvh;
padding: var(--sp-4, 1rem) var(--sp-3, 0.75rem);
border-right: 1px solid var(--c-border, rgba(255, 255, 255, 0.08));
background: var(--c-bg-1, #0a0a0a);
overflow-y: auto;
transition: width 0.25s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 40;
}
.app-sb--inline:where([data-rk-ad73bd]) { position: relative; height: auto; min-height: 520px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); border-radius: var(--r-lg, 16px); }
.app-sb--sm:where([data-rk-ad73bd]) { --sb-w: 220px; }
.app-sb--lg:where([data-rk-ad73bd]) { --sb-w: 280px; }
.app-sb--transparent:where([data-rk-ad73bd]) { background: transparent; }
.app-sb--accent:where([data-rk-ad73bd]) { background: var(--app-accent, var(--c-primary-light, #d4a017)); --c-text-1: #fff; --c-text-2: rgba(255, 255, 255, 0.8); }
.app-sb.is-collapsed:where([data-rk-ad73bd]) { --sb-w: 72px; }
.app-sb.is-collapsed:where([data-rk-ad73bd]) .app-sb__label:where([data-rk-ad73bd]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.app-sb.is-collapsed:where([data-rk-ad73bd]) .app-sb__top:where([data-rk-ad73bd]) { flex-direction: column; }
.app-sb__top:where([data-rk-ad73bd]) { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2, 0.5rem); }
.app-sb__ws:where([data-rk-ad73bd]) { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); min-width: 0; padding: 4px; border-radius: var(--r-md, 12px); color: var(--c-text-1, #fff); text-decoration: none; }
.app-sb__ws:hover:where([data-rk-ad73bd]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); color: var(--c-text-1, #fff); }
.app-sb__ws-logo:where([data-rk-ad73bd]) { display: grid; place-items: center; flex-shrink: 0; width: 36px; height: 36px; border-radius: 10px; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); font-size: 0.8rem; font-weight: 800; letter-spacing: 0.02em; }
.app-sb__ws-text:where([data-rk-ad73bd]) { display: grid; min-width: 0; line-height: 1.2; }
.app-sb__ws-text:where([data-rk-ad73bd]) strong:where([data-rk-ad73bd]) { font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.app-sb__ws-text:where([data-rk-ad73bd]) small:where([data-rk-ad73bd]) { font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); }
.app-sb__collapse:where([data-rk-ad73bd]) { display: grid; place-items: center; flex-shrink: 0; width: 36px; height: 36px; border: 0; border-radius: 10px; background: transparent; color: var(--c-text-2, #a3a3a3); cursor: pointer; }
.app-sb__collapse:hover:where([data-rk-ad73bd]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.06); color: var(--c-text-1, #fff); }
.app-sb__nav:where([data-rk-ad73bd]) { display: grid; gap: var(--sp-4, 1rem); }
.app-sb__group:where([data-rk-ad73bd]) ul:where([data-rk-ad73bd]) { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }
.app-sb__group-label:where([data-rk-ad73bd]) { margin: 0 0 var(--sp-1, 0.25rem); padding: 0 var(--sp-3, 0.75rem); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--c-text-3, #737373); }
.app-sb__link:where([data-rk-ad73bd]) {
position: relative;
display: flex;
align-items: center;
gap: var(--sp-3, 0.75rem);
min-height: 40px;
padding: 0 var(--sp-3, 0.75rem);
border-radius: 10px;
color: var(--c-text-2, #a3a3a3);
font-size: 0.88rem;
font-weight: 500;
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.app-sb__link:hover:where([data-rk-ad73bd]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); color: var(--c-text-1, #fff); }
.app-sb__link.is-active:where([data-rk-ad73bd]) { background: color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 14%, transparent); color: var(--c-text-1, #fff); font-weight: 600; }
.app-sb__link.is-active:where([data-rk-ad73bd]) .app-sb__icon:where([data-rk-ad73bd]) { color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.app-sb__icon:where([data-rk-ad73bd]) { display: grid; place-items: center; flex-shrink: 0; width: 20px; }
.app-sb__text:where([data-rk-ad73bd]) { flex: 1; white-space: nowrap; }
.app-sb__badge:where([data-rk-ad73bd]) { min-width: 22px; padding: 1px 7px; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.08); color: var(--c-text-1, #fff); font-size: 0.7rem; font-weight: 700; text-align: center; }
.app-sb__link.is-active:where([data-rk-ad73bd]) .app-sb__badge:where([data-rk-ad73bd]) { background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
.app-sb.is-collapsed:where([data-rk-ad73bd]) .app-sb__link:where([data-rk-ad73bd]) { justify-content: center; padding: 0; }
.app-sb.is-collapsed:where([data-rk-ad73bd]) .app-sb__link:is(:hover, :focus-visible):where([data-rk-ad73bd])::after {
content: attr(data-tip);
position: absolute;
left: calc(100% + 10px);
top: 50%;
transform: translateY(-50%);
padding: 4px 8px;
border-radius: 6px;
background: var(--c-text-1, #fff);
color: var(--c-bg, #050505);
font-size: 0.75rem;
white-space: nowrap;
z-index: 5;
}
.app-sb:where([data-rk-ad73bd]) :is(a, button):focus-visible:where([data-rk-ad73bd]) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.app-sb__promo:where([data-rk-ad73bd]) {
display: grid;
gap: 6px;
margin-top: auto;
padding: var(--sp-4, 1rem);
border-radius: 14px;
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08));
background: rgb(var(--ink-rgb, 255 255 255) / 0.03);
color: var(--c-text-2, #a3a3a3);
font-size: 0.8rem;
text-decoration: none;
}
.app-sb__promo:where([data-rk-ad73bd]) strong:where([data-rk-ad73bd]) { color: var(--c-text-1, #fff); font-size: 0.85rem; }
.app-sb__promo:where([data-rk-ad73bd]) em:where([data-rk-ad73bd]) { font-style: normal; font-weight: 600; color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.app-sb__meter:where([data-rk-ad73bd]) { height: 6px; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.08); overflow: hidden; }
.app-sb__meter:where([data-rk-ad73bd]) i:where([data-rk-ad73bd]) { display: block; height: 100%; border-radius: inherit; background: var(--app-accent, var(--c-primary-light, #d4a017)); }
.app-sb.is-collapsed:where([data-rk-ad73bd]) .app-sb__promo:where([data-rk-ad73bd]) { display: none; }
.app-sb-backdrop:where([data-rk-ad73bd]) { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, 0.5); }
.app-sb-backdrop[hidden]:where([data-rk-ad73bd]) { display: none; }
@media (max-width: 960px) {
.app-sb:not(.app-sb--inline):where([data-rk-ad73bd]) { position: fixed; left: 0; top: 0; transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); --sb-w: 280px; box-shadow: 20px 0 60px -20px rgba(0, 0, 0, 0.5); }
.app-sb.is-open:where([data-rk-ad73bd]) { transform: none; }
.app-sb.is-collapsed:where([data-rk-ad73bd]) { --sb-w: 280px; }
.app-sb.is-collapsed:where([data-rk-ad73bd]) .app-sb__label:where([data-rk-ad73bd]) { position: static; width: auto; height: auto; clip: auto; }
.app-sb__collapse:where([data-rk-ad73bd]) { display: none; }
}
@media (prefers-reduced-motion: reduce) { .app-sb:where([data-rk-ad73bd]) { transition: none; } }Quick Info
- Category
- App & Dashboard
- Filename
AppSidebar.tsx- Dependencies
- None โ pure React + CSS
- Tags
- appdashboardsaassidebarnavigationshell