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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/nav-inferiorEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
items? | NavInferiorItem[] | Opcional. |
value? | string | Opcional. |
defaultValue? | string | Opcional. |
onChange? | (id: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".