Menú a pantalla completa editorial
Panel negro que baja con clip-path y enlaces gigantes que suben escalonados; Escape cierra y el foco queda atrapado.
Diseñamos lo que viene después.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
/** Menú a pantalla completa dentro de su contenedor: los enlaces gigantes suben con escalonado, Escape cierra y el foco vuelve al botón. Estilo editorial en negro y crema. React + TypeScript, sin dependencias. */
export type NavPantallaProps = {
brand?: string;
links?: { label: string; href?: string; note?: string }[];
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcnpc-root { box-sizing: border-box; position: relative; overflow: hidden; width: 100%; min-height: 480px; background: #f1ede4; color: #131313; font: 500 16px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcnpc-root *, .gcnpc-root *::before, .gcnpc-root *::after { box-sizing: border-box; }
.gcnpc-root button, .gcnpc-root a { -webkit-tap-highlight-color: transparent; }
.gcnpc-root :focus-visible { outline: 2px solid #ff6b3d; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .gcnpc-root * { transition-duration: .01ms !important; animation: none !important; } }
.gcnpc-head { position: relative; z-index: 3; display: flex; align-items: center; justify-content: space-between; padding: 22px clamp(18px, 4vw, 40px); }
.gcnpc-brand { font: 700 20px/1 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.02em; transition: color .3s; }
.gcnpc-btn { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 6px 0 16px; border: 1.5px solid currentColor; border-radius: 999px; background: none; color: inherit; font-weight: 600; cursor: pointer; transition: color .3s, background .2s; }
.gcnpc-btn i { position: relative; width: 32px; height: 32px; border-radius: 50%; background: currentColor; }
.gcnpc-btn i::before, .gcnpc-btn i::after { content: ""; position: absolute; left: 9px; right: 9px; top: 50%; height: 2px; margin-top: -1px; background: #f1ede4; transition: transform .4s cubic-bezier(.65, 0, .35, 1); }
.gcnpc-btn i::before { transform: translateY(-3px); }
.gcnpc-btn i::after { transform: translateY(3px); }
.gcnpc-root[data-open="true"] .gcnpc-btn i::before { transform: rotate(45deg); }
.gcnpc-root[data-open="true"] .gcnpc-btn i::after { transform: rotate(-45deg); }
.gcnpc-root[data-open="true"] .gcnpc-head { color: #f1ede4; }
.gcnpc-root[data-open="true"] .gcnpc-btn i { background: #f1ede4; }
.gcnpc-root[data-open="true"] .gcnpc-btn i::before, .gcnpc-root[data-open="true"] .gcnpc-btn i::after { background: #131313; }
.gcnpc-panel { position: absolute; inset: 0; z-index: 2; display: grid; align-content: center; padding: 96px clamp(18px, 4vw, 40px) 40px; background: #131313; color: #f1ede4; clip-path: inset(0 0 100% 0); transition: clip-path .7s cubic-bezier(.76, 0, .24, 1), visibility 0s linear .7s; visibility: hidden; }
.gcnpc-root[data-open="true"] .gcnpc-panel { clip-path: inset(0); visibility: visible; transition-delay: 0s, 0s; }
.gcnpc-list { margin: 0; padding: 0; list-style: none; }
.gcnpc-row { overflow: hidden; border-top: 1px solid #333; }
.gcnpc-row:last-child { border-bottom: 1px solid #333; }
.gcnpc-link { display: flex; align-items: baseline; justify-content: space-between; gap: 18px; padding: 10px 0; color: inherit; text-decoration: none; font: 500 clamp(36px, 8vw, 84px)/1 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; transform: translateY(105%); transition: transform .8s cubic-bezier(.16, 1, .3, 1), color .2s, padding .3s; }
.gcnpc-root[data-open="true"] .gcnpc-link { transform: none; transition-delay: calc(.25s + var(--i) * 70ms), 0s, 0s; }
.gcnpc-link:hover { color: #ff6b3d; padding-left: 14px; }
.gcnpc-note { font: 500 13px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; color: #8b867a; }
.gcnpc-hero { display: grid; place-items: center; min-height: 380px; padding: 24px; text-align: center; }
.gcnpc-hero p { margin: 0; max-width: 18ch; font: 500 clamp(30px, 6vw, 60px)/1.02 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; }`;
const DEFAULT_LINKS = [
{ label: "Trabajo", note: "24 proyectos" },
{ label: "Estudio", note: "Quiénes somos" },
{ label: "Notas", note: "Blog" },
{ label: "Contacto", note: "Hablemos" },
];
export function NavPantalla({ brand = "Estudio Sur", links = DEFAULT_LINKS, className = "", style }: NavPantallaProps) {
const [open, setOpen] = useState(false);
const btn = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") { setOpen(false); btn.current?.focus(); }
if (event.key === "Tab") {
const focusables = [btn.current, ...(panel.current?.querySelectorAll<HTMLElement>("a") ?? [])].filter(Boolean) as HTMLElement[];
const first = focusables[0], last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<div className={`gcnpc-root ${className}`} style={style} data-open={open}>
<style>{CSS}</style>
<header className="gcnpc-head">
<span className="gcnpc-brand">{brand}</span>
<button ref={btn} className="gcnpc-btn" type="button" aria-expanded={open} aria-controls="gcnpc-panel" onClick={() => setOpen((value) => !value)}>
{open ? "Cerrar" : "Menú"}
<i aria-hidden="true" />
</button>
</header>
<div className="gcnpc-hero" aria-hidden={open}>
<p>Diseñamos lo que viene después.</p>
</div>
<div ref={panel} id="gcnpc-panel" className="gcnpc-panel" role="dialog" aria-label="Menú principal" aria-modal="false" hidden={false}>
<ul className="gcnpc-list">
{links.map((link, index) => (
<li key={link.label} className="gcnpc-row">
<a className="gcnpc-link" href={link.href ?? "#"} tabIndex={open ? 0 : -1} style={{ "--i": index } as CSSProperties} onClick={(event) => { if (!link.href) event.preventDefault(); setOpen(false); btn.current?.focus(); }}>
{link.label}
{link.note ? <span className="gcnpc-note">{link.note}</span> : null}
</a>
</li>
))}
</ul>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/nav-pantallaEjemplo de uso de Menú a pantalla completa editorial
Después de copiar nav-pantalla.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { NavPantalla } from "./nav-pantalla";
export default function Ejemplo() {
return <NavPantalla brand="Ejemplo" />;
}Propiedades de NavPantalla
NavPantalla acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
brand? | string | Opcional. |
links? | { label: string; href?: string; note?: string }[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 98 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Menú a pantalla completa editorial 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: dialog.
- Responde al teclado, no solo al ratón.
- Es un componente de cliente de Next.js: incluye la directiva "use client".