Navbar

Vista previa
SólidoSuperficie rellena, sin borde.
Uso
import { Navbar } from "@/components/library/navbar";
export default function Example() {  return <Navbar version="solid" />;}
navbar.tsx
"use client";
import { type MouseEvent, useState } from "react";import { ThemeToggle } from "@/components/theme/theme-toggle";import { cn } from "@/lib/cn";
export type NavbarVersion = "border" | "solid" | "blur";
/** * ──────────────────────────────────────────────────────────────── *  SHAPE — shared by every version, never changes between them. * *  FLARE  concave radius where the bar meets the top edge. The sides *         curve *outwards* into the ceiling, like the macOS notch. *  RADIUS convex radius on the two bottom corners. * ──────────────────────────────────────────────────────────────── */const FLARE = 24;const RADIUS = 22;
/** * Carves the concave corner: everything within FLARE of the wing's inner * bottom corner is masked away, leaving the arc that meets the bar's side * vertically and the ceiling horizontally. */function flareMask(side: "left" | "right") {  const origin = side === "left" ? "0% 100%" : "100% 100%";  return `radial-gradient(circle at ${origin}, transparent ${FLARE}px, #000 ${FLARE + 0.5}px)`;}
/** * ──────────────────────────────────────────────────────────────── *  VERSIONS *  Only the fill changes — background, backdrop and outline. *  The silhouette above is identical in all of them. * ──────────────────────────────────────────────────────────────── */const STYLES = {  border: {    fill: "bg-background",    // Traces a 1px rim around the whole silhouette, curves included.    outline:      "[filter:drop-shadow(0_1px_0_var(--border))_drop-shadow(0_-1px_0_var(--border))_drop-shadow(1px_0_0_var(--border))_drop-shadow(-1px_0_0_var(--border))]",  },  solid: {    fill: "bg-surface-2",    outline: "",  },  blur: {    fill: "bg-muted/80 backdrop-blur-3xl",    outline: "",  },} satisfies Record<NavbarVersion, Record<string, string>>;
const NAV = ["Work", "Components", "Contact"];
const DEFAULT_LABELS = {  nav: NAV,  contact: "Contact",  getInTouch: "Get in touch",  browse: "Browse components",  mainLabel: "Main",  menuLabel: "Toggle menu",};
const MENU_ID = "library-navbar-menu";
export function Navbar({  version = "border",  position = "fixed",  layout = "responsive",  locale = "en",  labels = DEFAULT_LABELS,  className,}: {  version?: NavbarVersion;  /**   * `absolute` pins the bar to its nearest positioned ancestor instead of the   * viewport — that is what keeps it inside a preview box.   */  position?: "fixed" | "absolute";  /**   * `mobile` holds the small-screen bar at every width — logo, one call to   * action, menu button — for places too narrow to earn the full layout.   */  layout?: "responsive" | "mobile";  locale?: "en" | "es";  labels?: {    nav: readonly string[];    contact: string;    getInTouch: string;    browse: string;    mainLabel: string;    menuLabel: string;  };  className?: string;}) {  const s = STYLES[version];  const [open, setOpen] = useState(false);  const tight = layout === "mobile";
  // Demo links: they navigate nowhere.  const hold = (event: MouseEvent<HTMLAnchorElement>) => event.preventDefault();
  return (    <div      className={cn(        "inset-x-0 top-0 z-50 flex w-full flex-col items-center px-6",        // Spelled out so Tailwind sees both classes in the source.        position === "absolute" ? "absolute" : "fixed",        className,      )}    >      <div className="relative w-full max-w-3xl">        {/* Shape layer. Kept free of text so the outline filter traces only            the silhouette and never haloes the content. It spans the bar *and*            the drawer below it, so opening the menu simply stretches the same            silhouette — the flares are pinned to `top-0` at a fixed size, which            leaves the concave corners untouched however tall the bar grows. */}        <div aria-hidden="true" className={cn("absolute inset-0", s.outline)}>          <span            className={cn("absolute top-0 right-full", s.fill)}            style={{              width: FLARE,              height: FLARE,              maskImage: flareMask("left"),              WebkitMaskImage: flareMask("left"),            }}          />          <span            className={cn("absolute inset-0", s.fill)}            style={{ borderRadius: `0 0 ${RADIUS}px ${RADIUS}px` }}          />          <span            className={cn("absolute top-0 left-full", s.fill)}            style={{              width: FLARE,              height: FLARE,              maskImage: flareMask("right"),              WebkitMaskImage: flareMask("right"),            }}          />        </div>
        {/* Content sits above the shape. */}        <nav          aria-label={labels.mainLabel}          className="relative flex h-14 items-center gap-3 px-4 sm:gap-6"        >          <a            href="#"            onClick={hold}            className="flex shrink-0 items-center gap-2.5"          >            <span className="text-xl font-medium tracking-tight text-foreground">              Monza<span className="text-primary">.</span>            </span>          </a>
          <ul className={cn("hidden items-center gap-1", !tight && "sm:flex")}>            {labels.nav.map((link) => (              <li key={link}>                <a                  href="#"                  onClick={hold}                  className="rounded-full px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:text-foreground"                >                  {link}                </a>              </li>            ))}          </ul>
          <div className="ml-auto flex items-center gap-2">            {tight ? (              <a                href="#"                onClick={hold}                className="inline-flex h-7 shrink-0 items-center rounded-lg bg-primary px-3.5 text-[13px] font-semibold tracking-tight text-white transition-opacity hover:opacity-80"              >                {labels.contact}              </a>            ) : (              <>                <ThemeToggle locale={locale} />                <a                  href="#"                  onClick={hold}                  className="hidden h-7 shrink-0 items-center rounded-lg bg-muted px-3.5 text-[13px] font-semibold tracking-tight text-foreground transition-opacity hover:opacity-80 hover:text-foreground sm:inline-flex"                >                  {labels.getInTouch}                </a>                <a                  href="#"                  onClick={hold}                  className="hidden h-7 shrink-0 items-center rounded-lg bg-primary px-3.5 text-[13px] font-semibold tracking-tight text-white transition-opacity hover:opacity-80 hover:text-foreground sm:inline-flex"                >                  {labels.browse}                </a>              </>            )}
            <button              type="button"              onClick={() => setOpen((value) => !value)}              aria-expanded={open}              aria-controls={MENU_ID}              aria-label={labels.menuLabel}              className={cn(                "inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground",                !tight && "sm:hidden",              )}            >              {/* Two bars folding into an X. `justify-between` inside a 10px                  box leaves each bar 4px off centre — exactly the distance the                  translate closes before the rotation lands. */}              <span                aria-hidden="true"                className="flex h-2.5 w-5 flex-col justify-between"              >                <span                  className={cn(                    "block h-0.5 w-full origin-center rounded-full bg-current transition-transform duration-300 ease-in-out motion-reduce:transition-none",                    open && "translate-y-[4px] rotate-45",                  )}                />                <span                  className={cn(                    "block h-0.5 w-full origin-center rounded-full bg-current transition-transform duration-300 ease-in-out motion-reduce:transition-none",                    open && "-translate-y-[4px] -rotate-45",                  )}                />              </span>            </button>          </div>        </nav>
        {/* Drawer. Lives inside the shape box so the bar itself grows around            it. `grid-rows-[0fr → 1fr]` animates the height without pinning a            magic max-height; the inner `overflow-hidden` is what lets the row            collapse past its content. */}        <div          id={MENU_ID}          inert={!open}          className={cn(            "relative grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none",            !tight && "sm:hidden",            open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",          )}        >          <div className="overflow-hidden px-4">            <ul className="space-y-0.5 border-t border-border pt-2 pb-3">              {labels.nav.map((link, index) => (                <li                  key={link}                  className={cn(                    "transition-[opacity,transform] duration-300 ease-out motion-reduce:transition-none",                    open                      ? "translate-y-0 opacity-100"                      : "-translate-y-1 opacity-0",                  )}                  // Items trail the expansion on the way in, and leave at once.                  style={{                    transitionDelay: open ? `${80 + index * 50}ms` : "0ms",                  }}                >                  <a                    href="#"                    onClick={(event) => {                      hold(event);                      setOpen(false);                    }}                    className="block rounded-lg px-3 py-2.5 text-[15px] text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"                  >                    {link}                  </a>                </li>              ))}            </ul>          </div>        </div>      </div>    </div>  );}
DependenciasNavbar importa estos archivos del proyecto. Copialos también si no tenés un equivalente.
components/theme/theme-toggle.tsx
"use client";
import { Moon, Sun } from "reicon-react";import { LANDING_COPY, type Locale } from "@/lib/i18n";import { toggleTheme, useTheme } from "@/lib/theme";
export function ThemeToggle({  className = "",  locale = "en",}: {  className?: string;  locale?: Locale;}) {  const theme = useTheme();  const next = theme === "dark" ? "light" : "dark";  const label = LANDING_COPY[locale].theme[next];
  return (    <button      type="button"      onClick={toggleTheme}      aria-label={label}      title={label}      className={`inline-flex px-1.5 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground ${className}`.trim()}    >      <Sun        size={16}        weight="Filled"        aria-hidden="true"        className="hidden dark:block"      />      <Moon        size={16}        weight="Filled"        aria-hidden="true"        className="block dark:hidden"      />    </button>  );}
lib/theme.ts
"use client";
import { useSyncExternalStore } from "react";
export type Theme = "light" | "dark";
const STORAGE_KEY = "theme";
/** * The theme lives on <html> — the blocking script in `layout.tsx` puts it * there before first paint. React subscribes to that DOM state rather than * owning a second copy of it. */const listeners = new Set<() => void>();
function emit() {  for (const listener of listeners) listener();}
function readStored(): Theme | null {  try {    const stored = localStorage.getItem(STORAGE_KEY);    return stored === "light" || stored === "dark" ? stored : null;  } catch {    return null;  }}
function apply(theme: Theme) {  const root = document.documentElement;  const themeColor = theme === "dark" ? "#000000" : "#ffffff";
  root.classList.toggle("dark", theme === "dark");  root.style.colorScheme = theme;  root.style.backgroundColor = themeColor;  document    .querySelector<HTMLMetaElement>('meta[name="theme-color"]')    ?.setAttribute("content", themeColor);  document    .querySelector<HTMLMetaElement>('meta[name="color-scheme"]')    ?.setAttribute("content", theme);  emit();}
function getSnapshot(): Theme {  return document.documentElement.classList.contains("dark") ? "dark" : "light";}
/** Matches the script's fallback, so hydration renders what the server sent. */function getServerSnapshot(): Theme {  return "dark";}
let boundToOS = false;
function subscribe(onStoreChange: () => void) {  listeners.add(onStoreChange);
  // Follow the OS, but only until the visitor makes an explicit choice.  if (!boundToOS) {    boundToOS = true;    const media = window.matchMedia("(prefers-color-scheme: dark)");    media.addEventListener("change", (event) => {      if (readStored()) return;      apply(event.matches ? "dark" : "light");    });  }
  return () => {    listeners.delete(onStoreChange);  };}
export function setTheme(theme: Theme) {  try {    localStorage.setItem(STORAGE_KEY, theme);  } catch {    // Blocked storage: the theme still applies for this visit.  }  apply(theme);}
export function toggleTheme() {  setTheme(getSnapshot() === "dark" ? "light" : "dark");}
export function useTheme(): Theme {  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}
lib/i18n.ts
export type Locale = "en" | "es";
export const LANDING_COPY = {  en: {    nav: {      about: "About",      work: "Work",      skills: "Skills",      contact: "Get in touch",      mainLabel: "Main navigation",      menuLabel: "Toggle menu",      languageLabel: "Ver sitio en español",    },    hero: {      welcome: "Welcome to my personal site",      contact: "Get in touch",      work: "Featured Work",      productPrefix: "Product",      productSuffix: "",      rotatingWords: ["Designer", "Specialist", "Builder"],      rotatingLabel: "Designer, Specialist and Builder",    },    about: {      intro: "I'm Matias Monzalvo",      description:        "22yo design engineer based in Buenos Aires. I turn complex problems into clear, useful digital products, moving from early strategy to shipped details and building the systems that keep every experience coherent as it grows.\nThis is the tool I work with every day.",      facts: [        { value: "6 years", label: "Designing products" },        { value: "12+", label: "Featured Projects" },        { value: "Based in", label: "Buenos Aires, Argentina" },      ],    },    work: {      heading: "Featured Work",      description: (count: number) =>        `Apps, sites, products and the systems behind them. +${count} projects shipped end to end.`,      imageAlt: (title: string) => `Screenshot of ${title}`,    },    formation:      "None of it was luck. Every screen up there is the sum of what I studied, practised and rebuilt until it held — the training that turns a blank file into a system, and a system into something worth shipping.",    skills: {      heading: "Skills & Experience",      description:        "These are the tools, languages, and systems I use in my day-to-day work, along with the experiences that have shaped how I approach it.",    },    contact: {      heading: "Get in touch",      description:        "I'm open to working on innovative and ambitious projects with meaningful impact.",    },    theme: {      light: "Switch to light theme",      dark: "Switch to dark theme",    },    visuals: {      aiLabel:        "Claude Code, Codex and Cursor feeding into one pair of hands, and one result out the far side",      education: [        "University",        "App Flows",        "C1 Cambridge",        "Hackathons",        "Courses",        "Side Projects",        "Sales",        "Customer Support",      ],    },    footer: "Designed and built in the open.",    componentShowcase: {      versionLabel: "Component version",      preview: "Preview",      usage: "Usage",      dependencies: "Dependencies",      dependencyDescription: (name: string) =>        `${name} imports these from the project. Copy them across too if you have no equivalent.`,      copy: "Copy",      copied: "Copied",    },  },  es: {    nav: {      about: "Sobre mí",      work: "Proyectos",      skills: "Habilidades",      contact: "Contactame",      mainLabel: "Navegación principal",      menuLabel: "Abrir o cerrar el menú",      languageLabel: "View site in English",    },    hero: {      welcome: "Bienvenido a mi sitio personal",      contact: "Contactame",      work: "Proyectos destacados",      productPrefix: "",      productSuffix: " de productos",      rotatingWords: ["Diseñador", "Estratega", "Creador"],      rotatingLabel: "Diseñador, estratega y creador",    },    about: {      intro: "Soy Matias Monzalvo",      description:        "Soy un design engineer de 22 años que vive en Buenos Aires. Convierto problemas complejos en productos digitales claros y útiles: avanzo desde la estrategia inicial hasta los detalles del lanzamiento y construyo los sistemas que mantienen cada experiencia coherente a medida que crece.\nEsta es la herramienta con la que trabajo todos los días.",      facts: [        { value: "6 años", label: "Diseñando productos" },        { value: "12+", label: "Proyectos Destacados" },        { value: "Vivo en", label: "Buenos Aires, Argentina" },      ],    },    work: {      heading: "Proyectos destacados",      description: (count: number) =>        `Apps, sitios, productos y los sistemas que los sostienen: +${count} proyectos desarrollados de principio a fin.`,      imageAlt: (title: string) => `Captura de pantalla de ${title}`,    },    formation:      "Nada fue cuestión de suerte. Cada pantalla que viste es el resultado de lo que estudié, practiqué y reconstruí hasta que funcionó: la formación que transforma un archivo en blanco en un sistema, y un sistema en algo que vale la pena lanzar.",    skills: {      heading: "Habilidades y experiencia",      description:        "Estas son las herramientas, los lenguajes y los sistemas que uso en mi trabajo diario, junto con las experiencias que definieron mi manera de abordar cada proyecto.",    },    contact: {      heading: "Contactame",      description:        "Estoy abierto a trabajar en proyectos innovadores y ambiciosos que generen un impacto significativo.",    },    theme: {      light: "Cambiar al tema claro",      dark: "Cambiar al tema oscuro",    },    visuals: {      aiLabel:        "Claude Code, Codex y Cursor aportan a un mismo proceso de trabajo que produce un único resultado",      education: [        "Universidad",        "Flujos de apps",        "Cambridge C1",        "Hackatones",        "Cursos",        "Proyectos propios",        "Ventas",        "Atención al cliente",      ],    },    footer: "Diseñado y desarrollado de manera abierta.",    componentShowcase: {      versionLabel: "Versión del componente",      preview: "Vista previa",      usage: "Uso",      dependencies: "Dependencias",      dependencyDescription: (name: string) =>        `${name} importa estos archivos del proyecto. Copialos también si no tenés un equivalente.`,      copy: "Copiar",      copied: "Copiado",    },  },} as const;
lib/cn.ts
/** Joins conditional class names. */export function cn(  ...classes: Array<string | false | null | undefined>): string {  return classes.filter(Boolean).join(" ");}

© 2026 Monza. Diseñado y desarrollado de manera abierta.

Next.js · Tailwind · WebGL