1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
App y Dashboardappdashboardsaassidebarnavigationshell

App Sidebar

Barra lateral SaaS con selector de espacio de trabajo, navegación agrupada con iconos y contadores, modo compacto y tarjeta de plan.

Vista Previa en Vivo

Uso

Copia y pega este código directamente en tu componente React:

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 }] }]} />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: 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; } }

Información Rápida

Categoría
App y Dashboard
Nombre de archivo
AppSidebar.tsx
Dependencias
Ninguna — React + CSS puro
Etiquetas
appdashboardsaassidebarnavigationshell