Ir al contenido
Evaluar mi proyecto
Componentes · BotonesDisponible

Botón de línea editorial

Enlace minimalista: el texto rueda hacia su copia en cursiva, la línea se redibuja y la flecha cruza la esquina.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

boton-linea.tsx71 líneas
Descargar
"use client";

import type { CSSProperties, ReactNode } from "react";

/** Botón minimalista editorial: el texto rueda a su copia, la línea se redibuja y la flecha cruza. React + TypeScript, sin dependencias. Personaliza con --gclin-*. */
export type BotonLineaProps = {
  children?: string;
  href?: string;
  onClick?: () => void;
  icon?: ReactNode;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gclin-root { box-sizing: border-box; display: inline-block; max-width: 100%; color: var(--gclin-fg, #151515); font: 500 17px/1.2 "Newsreader", Georgia, ui-serif, serif; }
.gclin-root *, .gclin-root *::before, .gclin-root *::after { box-sizing: border-box; }
.gclin-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gclin-root :focus-visible { outline: 2px solid var(--gclin-fg, #151515); outline-offset: 6px; }
@media (prefers-reduced-motion: reduce) { .gclin-root *, .gclin-root *::before, .gclin-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gclin-link { position: relative; display: inline-flex; align-items: center; gap: 14px; padding: 12px 0; border: 0; background: none; color: inherit; text-decoration: none; cursor: pointer; }
.gclin-line { position: absolute; left: 0; right: 0; bottom: 6px; height: 1px; background: currentColor; transform-origin: right; transition: transform .5s cubic-bezier(.76, 0, .24, 1); }
.gclin-link:hover .gclin-line, .gclin-link:focus-visible .gclin-line { animation: gclin-redraw .7s cubic-bezier(.76, 0, .24, 1); }
.gclin-roll { position: relative; display: inline-block; overflow: hidden; height: 1.2em; }
.gclin-roll > span { display: block; transition: transform .5s cubic-bezier(.76, 0, .24, 1); }
.gclin-roll > span + span { position: absolute; left: 0; top: 100%; font-style: italic; }
.gclin-link:hover .gclin-roll > span, .gclin-link:focus-visible .gclin-roll > span { transform: translateY(-100%); }
.gclin-arrow { position: relative; overflow: hidden; width: 22px; height: 22px; flex-shrink: 0; }
.gclin-arrow svg { position: absolute; inset: 0; transition: transform .5s cubic-bezier(.76, 0, .24, 1); }
.gclin-arrow svg + svg { transform: translate(-130%, 130%); }
.gclin-link:hover .gclin-arrow svg:first-child, .gclin-link:focus-visible .gclin-arrow svg:first-child { transform: translate(130%, -130%); }
.gclin-link:hover .gclin-arrow svg + svg, .gclin-link:focus-visible .gclin-arrow svg + svg { transform: translate(0, 0); }
@keyframes gclin-redraw { 0% { transform: scaleX(1); transform-origin: right; } 45% { transform: scaleX(0); transform-origin: right; } 46% { transform: scaleX(0); transform-origin: left; } 100% { transform: scaleX(1); transform-origin: left; } }`;

const Arrow = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d="M6 18 18 6M8 6h10v10" />
  </svg>
);

export function BotonLinea({ children = "Ver el proyecto", href, onClick, icon, className = "", style }: BotonLineaProps) {
  const inner = (
    <>
      <span className="gclin-roll" aria-hidden="true">
        <span>{children}</span>
        <span>{children}</span>
      </span>
      <span className="sr-only" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>{children}</span>
      <span className="gclin-arrow">
        {icon ?? Arrow}
        {icon ?? Arrow}
      </span>
      <span className="gclin-line" />
    </>
  );
  return (
    <span className={`gclin-root ${className}`} style={style}>
      <style>{CSS}</style>
      {href ? (
        <a className="gclin-link" href={href} onClick={onClick}>
          {inner}
        </a>
      ) : (
        <button className="gclin-link" type="button" onClick={onClick}>
          {inner}
        </button>
      )}
    </span>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/boton-linea

Ejemplo de uso de Botón de línea editorial

Después de copiar boton-linea.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { BotonLinea } from "./boton-linea";

export default function Ejemplo() {
  return <BotonLinea children="Ejemplo">Contenido</BotonLinea>;
}

Propiedades de BotonLinea

BotonLinea acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
children?stringOpcional.
href?stringOpcional.
onClick?() => voidOpcional.
icon?ReactNodeOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Botón de línea editorial con variables CSS

Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.

VariableValor por defecto
--gclin-fg#151515

Accesibilidad y rendimiento

El archivo tiene 71 líneas (4 KB) y define 1 animación con @keyframes. Esto es lo que hace Botón de línea 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).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)