NavLink
A navigation link that shows active state as you move through the app. Sidebars and top nav.
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
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| size | select | default | `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. |
| active | boolean | false | `true` / `false` | Marks the link as the current page. | true only for the matching route (sets aria-current=page). Default: false. |
| disabled | boolean | false | `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>
| Clase | Propósito |
|---|---|
nav-link | root |
nav-link--active | modifier |
nav-link--default | modifier |
nav-link--disabled | modifier |
nav-link--dropdown | modifier |
nav-link--lg | modifier |
nav-link--sm | modifier |
nav-link--underline-alt | modifier |
nav-link__caret | element |
nav-link__text | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | nav.link.fg.default | #525252 | nav.link.fg.default |
| all | nav.link.fg.disabled | #525252 | nav.link.fg.disabled |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
color | var(--duration-200) | ease |
rotate | var(--duration-300) | var(--easing-out) |
transform | var(--duration-600) | var(--easing-osmo) |
transform | var(--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
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
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-visibleon.nav-link:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.nav-link:hover:not(.nav-link--disabled):not(.nav-link--active)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.nav-link--active .nav-link__text::before— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.nav-link__caret svg— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.nav-link--dropdown[aria-expanded='true']— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.nav-link--dropdown[aria-expanded='true'] .nav-link__caret— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.nav-link--dropdown[aria-expanded='true'] .nav-link__text::before— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.nav-link__text::before— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
Tras pegar: Create component → nombre Atom / NavLink → Publish.