LinkButton
Inline text that shimmers as you hover, then navigates like a link. Secondary CTAs inside paragraphs or cards.
Sizes: xssmdefaultlgxl (default: default)
Incluye animación (GSAP / motion).
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Secondary CTA that navigates:
```tsx import { LinkButton } from '@/components/atoms/LinkButton';
<LinkButton href="https://docs.example.com" size="default" animated> Read the docs </LinkButton> ```
## Accesibilidad
- Ships as a real `<a href>` — keep a clear text label in `children` (string only). - Default `target="_blank"` + `rel="noopener noreferrer"`: warn users when leaving the app, or override `target` for same-tab in-app routes. - `disabled` sets `aria-disabled` but still renders an anchor — prefer removing the link or blocking navigation in the router when inactive.
### Correcto
- Siempre renderiza como `<a>` con href — es un link semantico, no un boton - target='_blank' + rel='noopener noreferrer' aplicados automaticamente - aria-disabled se agrega cuando `disabled={true}` - focus-visible muestra outline para navegacion por teclado - prefers-reduced-motion desactiva shimmer y transiciones
### Evitar
- No usar LinkButton para acciones (submit, delete) — usar Button con href o un `<button>` - No pasar JSX como children — solo string (necesario para el shimmer gradient) - No combinar con onClick para acciones — es un `<a>`, navega, no ejecuta
## Cuándo no usar
- In-page actions without navigation → `Button`. - Icon-only chrome → `IconButton`.
## Criterio de uso
- Úsalo cuando la acción cambia de ubicación y el texto debe fluir dentro de un párrafo, card o CTA secundario. - Conserva un `href` real. No conviertas el componente en un botón con `onClick`: perdería semántica, teclado y comportamiento esperado del navegador. - `animated` funciona mejor como énfasis puntual; déjalo apagado en listas legales, tablas o interfaces donde el usuario repite navegación.
## Gotchas
- El contenido animado debe ser texto; si necesitas composición rica o una acción, usa el componente apropiado. - Si el destino es externo y abre una pestaña nueva, comunica ese cambio en el contexto visible; si es navegación interna, configura el router para conservar la experiencia de la aplicación. - **Nota**: Los nombres de size son diferentes a Button: usa 'sm' y 'default' en vez de 's' y 'm'. Esto es porque LinkButton sigue la escala tipografica, no la escala de componentes interactivos. - **Nota**: CSS autocontenido. La animacion de underline y el shimmer son puro CSS — no necesitan JS. - **Nota**: Todas las animaciones son puro CSS. No requieren GSAP ni JS. El shimmer se desactiva automaticamente con prefers-reduced-motion: reduce.
Uso
import { LinkButton } from '@/components/atoms/LinkButton';
<LinkButton href="/docs" size="default" animated>
Read the docs
</LinkButton>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| size | select | default | `xs`, `sm`, `default`, `lg`, `xl` | Type and padding scale of the inline link control. | default for body copy; sm/xs for dense meta; lg/xl for hero links. Default: default. |
| disabled | boolean | false | `true` / `false` | Prevents navigation and dims the link. | Prefer removing the link over a dead disabled style when possible. Default: false. |
| animated | boolean | false | `true` / `false` | Whether the shimmer / text-swap motion runs on hover. | true for marketing CTAs in body; false in dense legal lists. Default: false. |
Gotchas
- a11y
Requires a real href for keyboard and screen readers; do not fake links with onClick only.
- react
children must be a string for animated text-swap paths.
Anatomía CSS
<div class="link-button"> <span class="link-button__text"></span> </div>
| Clase | Propósito |
|---|---|
link-button | root |
link-button--animated | modifier |
link-button--default | modifier |
link-button--disabled | modifier |
link-button--lg | modifier |
link-button--sm | modifier |
link-button--xl | modifier |
link-button--xs | modifier |
link-button__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 | link.button.fg.disabled | #525252 | link.button.fg.disabled |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
color | 0.3s | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
transform | 0.735s | cubic-bezier(0.625 |
0.05 | | |
0 | | |
1) | | |
@keyframes link-button-shimmer | | |
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).
/* -------------------------------------------------------------------------
Link Button
Semantic link (<a>) for external navigation. Not a button.
Blue text with animated underline on hover. No background, no border.
Sizes: xs, sm, default, lg, xl
States: enabled, hover, focus, disabled
------------------------------------------------------------------------- */
.link-button {
display: inline-flex;
align-items: center;
gap: 0.5em;
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: #0e7490;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
text-decoration: none;
transition: color 0.3s cubic-bezier(0.19, 1, 0.22, 1);
}
.link-button:hover:not(.link-button--disabled) {
color: #0a0a0a;
}
.link-button:active:not(.link-button--disabled) {
color: #0a0a0a;
}
.link-button:focus-visible {
outline: 2px solid #0a0a0a;
outline-offset: 1.5px;
border-radius: 0.125em;
}
@media (prefers-reduced-motion: reduce) {
.link-button {
transition-duration: 0ms;
}
}
/* ---- Sizes ---- */
.link-button--xs { font-size: 10.24px; }
.link-button--sm { font-size: 12.8px; }
.link-button--default { font-size: 16px; }
.link-button--lg { font-size: 20px; }
.link-button--xl { font-size: 25px; }
/* ---- States ---- */
.link-button--disabled {
color: #525252;
cursor: not-allowed;
pointer-events: none;
}
/* ---- Parts ---- */
.link-button__text {
display: inline;
position: relative;
}
/* ---- Underline animation ---- */
.link-button__text::before {
content: "";
position: absolute;
bottom: -0.0625em;
left: 0;
width: 100%;
height: 0.0625em;
background-color: currentColor;
transition: transform 0.735s cubic-bezier(0.625, 0.05, 0, 1);
transform-origin: right;
transform: scaleX(0) rotate(0.001deg);
}
@media (hover: hover) and (pointer: fine) {
.link-button--animated:hover:not(.link-button--disabled) .link-button__text::before,
[data-hover]:hover .link-button--animated:not(.link-button--disabled) .link-button__text::before {
transform-origin: left;
transform: scaleX(1) rotate(0.001deg);
}
}
/* ---- Animated variant: persistent underline + text shimmer ---- */
.link-button--animated:not(.link-button--disabled) .link-button__text::before {
transform-origin: left;
transform: scaleX(1) rotate(0.001deg);
}
.link-button--animated:not(.link-button--disabled) .link-button__text {
background: linear-gradient(
120deg,
currentColor 0%,
currentColor 35%,
color-mix(in srgb, currentColor 40%, white) 50%,
currentColor 65%,
currentColor 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: link-button-shimmer 3s ease-in-out infinite;
}
@keyframes link-button-shimmer {
0%, 100% { background-position: 200% center; }
50% { background-position: 0% center; }
}
@media (prefers-reduced-motion: reduce) {
.link-button--animated:not(.link-button--disabled) .link-button__text {
animation: none;
background: none;
-webkit-text-fill-color: currentColor;
}
}
Codigo fuente
import { forwardRef, type AnchorHTMLAttributes } from 'react'; type LinkButtonSize = 'xs' | 'sm' | 'default' | 'lg' | 'xl'; export type LinkButtonProps = { size?: LinkButtonSize; disabled?: boolean; animated?: boolean; children: string; className?: string; href: string; } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'children'>; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>( ( { size = 'default', disabled = false, animated = false, children, className, href, ...props }, ref, ) => { const classes = cn( 'link-button', `link-button--${size}`, disabled && 'link-button--disabled', animated && 'link-button--animated', className, ); return ( <a ref={ref} className={classes} href={href} aria-disabled={disabled || undefined} target="_blank" rel="noopener noreferrer" {...props} > <span className="link-button__text">{children}</span> </a> ); }, ); LinkButton.displayName = 'LinkButton';
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / LinkButton) 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"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/webflow.css">
Unsupported (no silencioso)
:focus-visibleon.link-button:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.link-button:hover:not(.link-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.link-button:active:not(.link-button--disabled)— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.link-button__text::before— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.link-button--animated:not(.link-button--disabled) .link-button__text::before— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.link-button--animated:not(.link-button--disabled) .link-button__text— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.link-button:focus-visible— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)@mediaon(prefers-reduced-motion: reduce)— not a Designer breakpoint — moved to head Custom Code block
Tras pegar: Create component → nombre Atom / LinkButton → Publish.