ATOM
Components (registry)Navigation

NavLink

A navigation link that shows active state as you move through the app. Sidebars and top nav.

Preview

Sizes: smdefaultlg (default: default)

Editorial

<!-- F12c editorial — non-derivable only. Review: Karen. -->

## Ejemplos

Active route in a nav list:

```tsx import { NavLink } from '@/components/atoms/NavLink';

<NavLink href="/docs" size="default" active> Docs </NavLink> ```

## Accesibilidad

- `children` is a string label; `active` sets `aria-current="page"` — one active link per section. - `disabled` sets `aria-disabled`; still prefer removing the destination when navigation is blocked.

## Cuándo no usar

- Page CTAs that leave the app shell → `LinkButton` / `Button`. - Icon-only chrome → `IconButton` with `aria-label`.

## Criterio de uso

- Úsalo para navegación persistente dentro del shell: sidebar, top nav o una lista de secciones. - Marca sólo la ruta actual como `active`; no uses el estado activo para indicar foco, hover o una selección temporal. - Mantén `href` y una etiqueta textual clara. Para acciones sin navegación, usa `Button`.

## Gotchas

- En cada sección debe existir como máximo un enlace con `aria-current="page"`. - El estado `disabled` no reemplaza una política de permisos: si el destino no debe existir para el usuario, omite el enlace o resuelve la autorización en el router.

Uso

import { NavLink } from '@/components/atoms/NavLink';

<NavLink href="/docs" size="default" active>
  Docs
</NavLink>

Props

PropTipoDefaultRango / opcionesWhatHow
sizeselectdefault`sm`, `default`, `lg`Text and hit-target scale of the link.default in main nav; sm in dense side lists; lg for marketing nav. Default: default.
activebooleanfalse`true` / `false`Marks the link as the current page.true only for the matching route (sets aria-current=page). Default: false.
disabledbooleanfalse`true` / `false`Visually disables the link.true for unavailable destinations; still provide href or omit navigation in the router layer. Default: false.

Gotchas

  • a11y

    children must be a string label; active sets aria-current=page — keep one active link per nav section.

  • react

    Requires href + children. For SPA routers, wrap or pass the framework Link via className/href patterns your app uses.

Anatomía CSS

<div class="nav-link">
  <span class="nav-link__caret"></span>
  <span class="nav-link__text"></span>
</div>
ClasePropósito
nav-linkroot
nav-link--activemodifier
nav-link--defaultmodifier
nav-link--disabledmodifier
nav-link--dropdownmodifier
nav-link--lgmodifier
nav-link--smmodifier
nav-link--underline-altmodifier
nav-link__caretelement
nav-link__textelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allnav.link.fg.default#525252nav.link.fg.default
allnav.link.fg.disabled#525252nav.link.fg.disabled

Animaciones

PropiedadDuraciónEasing
colorvar(--duration-200)ease
rotatevar(--duration-300)var(--easing-out)
transformvar(--duration-600)var(--easing-osmo)
transformvar(--duration-600)var(--easing-osmo)

CSS mínimo funcional

Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).

/* -------------------------------------------------------------------------
   Nav Link

   Internal navigation link. Child of Navbar.
   Neutral text, full contrast on hover/active. Underline on active.

   Sizes:    sm, default, lg
   States:   default, hover, active, disabled
   ------------------------------------------------------------------------- */

.nav-link {
  display: inline-flex;
  align-items: center;
  padding: 0;
  background: none;
  border: none;
  font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
  font-weight: 500;
  line-height: 1;
  color: #525252;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  text-decoration: none;
  /* ATOM --animation-ease (0.2s ease) para color; tracking de su nav-menu__label */
  letter-spacing: -0.02em;
  transition: color 200ms ease;
  position: relative;
}

.nav-link:hover:not(.nav-link--disabled):not(.nav-link--active) {
  color: #0a0a0a;
}

.nav-link:focus-visible {
  outline: 2px solid #0a0a0a;
  outline-offset: 1.5px;
  border-radius: 0.125em;
}

@media (prefers-reduced-motion: reduce) {
  .nav-link {
    transition-duration: 0ms;
  }
  .nav-link__caret {
    transition-duration: 0ms;
  }
}

/* ---- Sizes ---- */

.nav-link--sm      { font-size: 10.24px; }
.nav-link--default { font-size: 12.8px; }
.nav-link--lg      { font-size: 16px; }

/* ---- Active ---- */

.nav-link--active {
  color: #0a0a0a;
  cursor: default;
}

.nav-link--active .nav-link__text::before {
  transform-origin: left;
  transform: scaleX(1) rotate(0.001deg);
}

/* ---- Disabled ---- */

.nav-link--disabled {
  color: #525252;
  cursor: not-allowed;
  pointer-events: none;
}

/* ---- Parts ---- */

.nav-link__text {
  display: inline;
  position: relative;
}

/* ---- Dropdown variant ----
   Trigger de menu dentro de un navbar: caret que rota al abrir y el mismo
   subrayado del estado activo mientras el menu esta abierto. El estado lo
   lleva aria-expanded (accesibilidad = fuente de verdad del estilo). El panel
   es el componente dropdown-menu; aqui solo vive el trigger. */

.nav-link--dropdown {
  gap: 0.375em;
}

.nav-link__caret {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
  transition: rotate 300ms cubic-bezier(0.22, 1, 0.36, 1);
}

.nav-link__caret svg {
  width: 100%;
  height: 100%;
}

.nav-link--dropdown[aria-expanded='true'] {
  color: #0a0a0a;
}

.nav-link--dropdown[aria-expanded='true'] .nav-link__caret {
  rotate: 180deg;
}

.nav-link--dropdown[aria-expanded='true'] .nav-link__text::before {
  transform-origin: left;
  transform: scaleX(1) rotate(0.001deg);
}

/* ---- Underline ---- */

/* Contrato citado de ATOM [data-underline-link]: offset -0.0625em, alto
   0.0625em, transform 0.6s con la cubica firma, origen right->left. */
.nav-link__text::before {
  content: "";
  position: absolute;
  bottom: -0.0625em;
  left: 0;
  width: 100%;
  height: 0.0625em;
  background-color: #0a0a0a;
  transition: transform 600ms cubic-bezier(0.625, 0.05, 0, 1);
  transform-origin: right;
  transform: scaleX(0) rotate(0.001deg);
}

/* ---- Underline alt (ATOM [data-underline-link="alt"]) ----
   Para links que YA estan subrayados (footers, listas): al hover la linea sale
   por la derecha y una segunda reentra por la izquierda con 0.3s de delay. */

.nav-link--underline-alt .nav-link__text::before {
  transform-origin: left;
  transform: scaleX(1) rotate(0.001deg);
  transition-delay: 300ms;
}

.nav-link--underline-alt .nav-link__text::after {
  content: "";
  position: absolute;
  bottom: -0.0625em;
  left: 0;
  width: 100%;
  height: 0.0625em;
  background-color: #0a0a0a;
  transition: transform 600ms cubic-bezier(0.625, 0.05, 0, 1);
  transform-origin: right;
  transform: scaleX(0) rotate(0.001deg);
}

@media (hover: hover) and (pointer: fine) {
  .nav-link--underline-alt:hover:not(.nav-link--disabled) .nav-link__text::before {
    transform-origin: right;
    transform: scaleX(0) rotate(0.001deg);
    transition-delay: 0s;
  }
  .nav-link--underline-alt:hover:not(.nav-link--disabled) .nav-link__text::after {
    transform-origin: left;
    transform: scaleX(1) rotate(0.001deg);
    transition-delay: 300ms;
  }
}

@media (hover: hover) and (pointer: fine) {
  .nav-link:hover:not(.nav-link--disabled):not(.nav-link--active) .nav-link__text::before,
  [data-hover]:hover .nav-link:not(.nav-link--disabled):not(.nav-link--active) .nav-link__text::before {
    transform-origin: left;
    transform: scaleX(1) rotate(0.001deg);
  }
}

Codigo fuente

components-react
components/atoms/NavLink.tsx
import { forwardRef, type AnchorHTMLAttributes } from 'react';

type NavLinkSize = 'sm' | 'default' | 'lg';

export type NavLinkProps = {
  size?: NavLinkSize;
  active?: boolean;
  disabled?: boolean;
  children: string;
  href: string;
  className?: string;
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'children'>;

function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ');
}

export const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(
  (
    {
      size = 'default',
      active = false,
      disabled = false,
      children,
      href,
      className,
      ...props
    },
    ref,
  ) => {
    const classes = cn(
      'nav-link',
      `nav-link--${size}`,
      active && 'nav-link--active',
      disabled && 'nav-link--disabled',
      className,
    );

    return (
      <a
        ref={ref}
        className={classes}
        href={href}
        aria-current={active ? 'page' : undefined}
        aria-disabled={disabled || undefined}
        {...props}
      >
        <span className="nav-link__text">{children}</span>
      </a>
    );
  },
);

NavLink.displayName = 'NavLink';

Webflow

Webflownav-link

Pega en el Designer como application/json, luego convierte a Component (Atom / NavLink) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.

Setup del sitio (una vez)

Custom Code → Head:

<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css">
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">

Unsupported (no silencioso)

  • :focus-visible on .nav-link:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .nav-link:hover:not(.nav-link--disabled):not(.nav-link--active)compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .nav-link--active .nav-link__text::beforecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .nav-link__caret svgcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .nav-link--dropdown[aria-expanded='true']compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .nav-link--dropdown[aria-expanded='true'] .nav-link__caretcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .nav-link--dropdown[aria-expanded='true'] .nav-link__text::beforecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .nav-link__text::beforecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)

Tras pegar: Create component → nombre Atom / NavLink → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categorianavigation
Lectura...
Visitas...
Ayuda?Slack

Componente

NavLink

Source

components-react / cssDisponible via MCP: atom_uikit_source("nav-link")
Abrir en Storybook