Ir al contenido
Evaluar mi proyecto
Componentes · NavegaciónDisponible

Barra de pestañas inferior

Cápsula oscura con una pastilla que se desliza entre pestañas, icono que rebota y contador; flechas del teclado.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

nav-inferior.tsx82 líneas
Descargar
"use client";

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

/** Barra de pestañas inferior para móvil: cápsula oscura con una pastilla que se desliza al elegir, icono que rebota y contador. Roving tabindex con flechas. React + TypeScript, sin dependencias. */
export type NavInferiorItem = { id: string; label: string; icon: ReactNode; badge?: number };

export type NavInferiorProps = {
  items?: NavInferiorItem[];
  value?: string;
  defaultValue?: string;
  onChange?: (id: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcnin-root { box-sizing: border-box; display: block; width: 100%; max-width: 380px; font: 600 11px/1 -apple-system, "SF Pro Text", ui-sans-serif, system-ui, sans-serif; }
.gcnin-root *, .gcnin-root *::before, .gcnin-root *::after { box-sizing: border-box; }
.gcnin-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcnin-root :focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcnin-root * { animation: none !important; transition-duration: .01ms !important; } }
.gcnin-bar { position: relative; display: grid; grid-template-columns: repeat(var(--n), 1fr); padding: 6px; border-radius: 32px; background: rgba(28, 28, 32, .92); -webkit-backdrop-filter: blur(20px) saturate(1.6); backdrop-filter: blur(20px) saturate(1.6); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .1), inset 0 1px 0 rgba(255, 255, 255, .18), 0 18px 30px -12px rgba(0, 0, 0, .6); }
.gcnin-pill { position: absolute; top: 6px; bottom: 6px; left: 6px; width: calc((100% - 12px) / var(--n)); border-radius: 26px; background: rgba(255, 255, 255, .16); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .18), inset 0 1px 0 rgba(255, 255, 255, .3); transform: translateX(calc(var(--i) * 100%)); transition: transform .5s cubic-bezier(.34, 1.3, .64, 1); }
.gcnin-tab { position: relative; z-index: 1; display: grid; justify-items: center; gap: 5px; padding: 10px 0 9px; border: 0; background: none; color: rgba(255, 255, 255, .6); cursor: pointer; transition: color .2s; }
.gcnin-tab[aria-selected="true"] { color: #fff; }
.gcnin-tab[aria-selected="true"] .gcnin-ico { animation: gcnin-hop .5s cubic-bezier(.34, 1.56, .64, 1); }
.gcnin-ico { position: relative; width: 24px; height: 24px; }
.gcnin-ico svg { width: 100%; height: 100%; }
.gcnin-badge { position: absolute; top: -5px; right: -9px; min-width: 16px; height: 16px; padding: 0 4px; display: grid; place-items: center; border-radius: 99px; background: #ff453a; color: #fff; font-size: 10px; }
@keyframes gcnin-hop { 0% { transform: translateY(0) scale(1); } 40% { transform: translateY(-4px) scale(1.12); } 100% { transform: none; } }`;

const I = ({ d }: { d: string }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d={d} />
  </svg>
);

const DEFAULT_ITEMS: NavInferiorItem[] = [
  { id: "inicio", label: "Inicio", icon: <I d="m3 11 9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" /> },
  { id: "buscar", label: "Buscar", icon: <I d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.5-4.5" /> },
  { id: "avisos", label: "Avisos", icon: <I d="M6 9a6 6 0 1 1 12 0c0 6 2 7.5 2 7.5H4S6 15 6 9zM10 20a2 2 0 0 0 4 0" />, badge: 3 },
  { id: "perfil", label: "Perfil", icon: <I d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20c1-4 4-6 8-6s7 2 8 6" /> },
];

export function NavInferior({ items = DEFAULT_ITEMS, value, defaultValue, onChange, className = "", style }: NavInferiorProps) {
  const [inner, setInner] = useState(defaultValue ?? items[0].id);
  const current = value ?? inner;
  const index = Math.max(0, items.findIndex((item) => item.id === current));

  function select(id: string) {
    setInner(id);
    onChange?.(id);
  }
  function key(event: React.KeyboardEvent, i: number) {
    const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
    if (!step) return;
    event.preventDefault();
    const next = (i + step + items.length) % items.length;
    select(items[next].id);
    (event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>("button")[next])?.focus();
  }

  return (
    <nav className={`gcnin-root ${className}`} style={style} aria-label="Secciones">
      <style>{CSS}</style>
      <div className="gcnin-bar" role="tablist" style={{ "--n": items.length, "--i": index } as CSSProperties}>
        <span className="gcnin-pill" aria-hidden="true" />
        {items.map((item, i) => (
          <button key={item.id} className="gcnin-tab" type="button" role="tab" aria-selected={item.id === current} tabIndex={item.id === current ? 0 : -1} onClick={() => select(item.id)} onKeyDown={(event) => key(event, i)}>
            <span className="gcnin-ico">
              {item.icon}
              {item.badge ? <span className="gcnin-badge" aria-label={`${item.badge} nuevos`}>{item.badge}</span> : null}
            </span>
            {item.label}
          </button>
        ))}
      </div>
    </nav>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/nav-inferior

Ejemplo de uso de Barra de pestañas inferior

Después de copiar nav-inferior.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { NavInferior } from "./nav-inferior";

export default function Ejemplo() {
  return <NavInferior value="Ejemplo" />;
}

Propiedades de NavInferior

NavInferior acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
items?NavInferiorItem[]Opcional.
value?stringOpcional.
defaultValue?stringOpcional.
onChange?(id: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 82 líneas (5 KB) y define 1 animación con @keyframes. Esto es lo que hace Barra de pestañas inferior por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: tablist, tab.
  • Responde al teclado, no solo al ratón.
  • Usa backdrop-filter para el efecto de vidrio; en navegadores sin soporte el panel se ve sin desenfoque.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)