Ir al contenido
Evaluar mi proyecto
Componentes · HerosDisponible

Hero de linterna que revela otra capa

Un haz de luz sigue al puntero y descubre una segunda capa bajo la primera; en táctil el haz recorre la pantalla solo.

Tu operación hoy

Hojas de cálculo, mensajes sueltos y procesos que dependen de una sola persona. Funciona, hasta que deja de funcionar.

Ver cómo se conectaMueve la luz para ver la otra capa

Código completo

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

hero-linterna.tsx85 líneas
Descargar
"use client";

import { useRef, useState, type CSSProperties, type PointerEvent } from "react";

/** Hero de linterna: un haz de luz sigue al puntero y revela una segunda capa oculta bajo la primera. En táctil, el haz recorre la pantalla solo. React + TypeScript, sin dependencias. */
export type HeroLinternaProps = {
  before?: { eyebrow?: string; title: string; text: string };
  after?: { title: string; text: string; chips: string[] };
  href?: string;
  cta?: string;
  radius?: number;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
@property --gchlt-x { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --gchlt-y { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
.gchlt-root { box-sizing: border-box; position: relative; container-type: inline-size; width: 100%; min-height: 620px; overflow: hidden; background: #e9e4d6; color: #3a3630; font: 500 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; cursor: crosshair; --gchlt-x: 50%; --gchlt-y: 50%; touch-action: pan-y; }
.gchlt-root *, .gchlt-root *::before, .gchlt-root *::after { box-sizing: border-box; }
.gchlt-root a { -webkit-tap-highlight-color: transparent; }
.gchlt-root :focus-visible { outline: 3px solid #1f4fff; outline-offset: 4px; }
.gchlt-root[data-idle="true"] { animation: gchlt-roam 14s ease-in-out infinite alternate; }
@media (prefers-reduced-motion: reduce) { .gchlt-root[data-idle="true"] { animation: none; } }
.gchlt-layer { position: absolute; inset: 0; display: grid; align-content: center; gap: 22px; padding: clamp(28px, 7cqi, 96px); }
.gchlt-before { color: #6d675b; background: repeating-linear-gradient(0deg, transparent 0 31px, rgba(80, 70, 40, .12) 31px 32px), #e9e4d6; }
.gchlt-title { margin: 0; max-width: 12ch; font: 800 clamp(40px, 8cqi, 96px)/.98 ui-sans-serif, system-ui, -apple-system, sans-serif; letter-spacing: -.045em; text-wrap: balance; }
.gchlt-before .gchlt-title { color: #5b554a; filter: blur(.4px); }
.gchlt-text { margin: 0; max-width: 42ch; font-size: clamp(15px, 1.8cqi, 19px); text-wrap: pretty; }
.gchlt-note { position: absolute; right: 8%; top: 14%; padding: 14px 16px; background: #f3e68a; color: #5b4f00; font: 700 20px/1.2 "Caveat", "Bradley Hand", "Segoe Script", cursive; transform: rotate(5deg); box-shadow: 0 8px 14px -6px rgba(0, 0, 0, .3); }
.gchlt-after { color: #e9f0ff; background: radial-gradient(90% 90% at 70% 20%, #1f3aa8, #0a1246 70%); -webkit-mask-image: radial-gradient(circle var(--gchlt-r, 190px) at var(--gchlt-x) var(--gchlt-y), #000 0 62%, transparent 100%); mask-image: radial-gradient(circle var(--gchlt-r, 190px) at var(--gchlt-x) var(--gchlt-y), #000 0 62%, transparent 100%); }
.gchlt-after::before { content: ""; position: absolute; inset: 0; background-image: linear-gradient(rgba(140, 170, 255, .16) 1px, transparent 1px), linear-gradient(90deg, rgba(140, 170, 255, .16) 1px, transparent 1px); background-size: 44px 44px; }
.gchlt-after .gchlt-title { position: relative; color: #fff; }
.gchlt-after .gchlt-text { position: relative; color: #bcccff; }
.gchlt-chips { position: relative; display: flex; flex-wrap: wrap; gap: 8px; }
.gchlt-chip { padding: 7px 14px; border: 1px solid rgba(190, 210, 255, .5); border-radius: 999px; font-size: 13px; font-weight: 600; background: rgba(120, 150, 255, .18); }
.gchlt-cta { position: absolute; z-index: 3; left: clamp(28px, 7cqi, 96px); bottom: 36px; display: inline-flex; align-items: center; min-height: 50px; padding: 0 26px; border-radius: 4px; background: #14110c; color: #fff; font-weight: 600; text-decoration: none; cursor: pointer; }
.gchlt-cta:hover { background: #1f4fff; }
.gchlt-hint { position: absolute; z-index: 3; right: 24px; bottom: 36px; font-size: 12px; color: #6d675b; pointer-events: none; }
@keyframes gchlt-roam { 0% { --gchlt-x: 24%; --gchlt-y: 34%; } 33% { --gchlt-x: 72%; --gchlt-y: 28%; } 66% { --gchlt-x: 58%; --gchlt-y: 72%; } 100% { --gchlt-x: 30%; --gchlt-y: 66%; } }`;

const BEFORE = { title: "Tu operación hoy", text: "Hojas de cálculo, mensajes sueltos y procesos que dependen de una sola persona. Funciona, hasta que deja de funcionar." };
const AFTER = { title: "Tu operación, conectada", text: "Un sistema a la medida: datos en un solo lugar, procesos automáticos y trazabilidad de cada acción.", chips: ["Automatización", "Integraciones", "Reportes", "Auditoría"] };

export function HeroLinterna({ before = BEFORE, after = AFTER, href = "#", cta = "Ver cómo se conecta", radius = 190, className = "", style }: HeroLinternaProps) {
  const ref = useRef<HTMLElement>(null);
  const [idle, setIdle] = useState(true);

  function move(event: PointerEvent<HTMLElement>) {
    if (event.pointerType === "touch") return;
    const rect = event.currentTarget.getBoundingClientRect();
    setIdle(false);
    event.currentTarget.style.setProperty("--gchlt-x", `${((event.clientX - rect.left) / rect.width) * 100}%`);
    event.currentTarget.style.setProperty("--gchlt-y", `${((event.clientY - rect.top) / rect.height) * 100}%`);
  }

  return (
    <section ref={ref} className={`gchlt-root ${className}`} style={{ "--gchlt-r": `${radius}px`, ...style } as CSSProperties} data-idle={idle} onPointerMove={move} onPointerLeave={() => setIdle(true)}>
      <style>{CSS}</style>
      <div className="gchlt-layer gchlt-before">
        <h1 className="gchlt-title">{before.title}</h1>
        <p className="gchlt-text">{before.text}</p>
        <span className="gchlt-note" aria-hidden="true">
          ¿quién tiene el archivo bueno?
        </span>
      </div>
      <div className="gchlt-layer gchlt-after" aria-hidden="true">
        <h2 className="gchlt-title">{after.title}</h2>
        <p className="gchlt-text">{after.text}</p>
        <div className="gchlt-chips">
          {after.chips.map((chip) => (
            <span key={chip} className="gchlt-chip">
              {chip}
            </span>
          ))}
        </div>
      </div>
      <a className="gchlt-cta" href={href}>
        {cta}
      </a>
      <span className="gchlt-hint">Mueve la luz para ver la otra capa</span>
    </section>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

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

Ejemplo de uso de Hero de linterna que revela otra capa

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

ejemplo.tsx5 líneas
import { HeroLinterna } from "./hero-linterna";

export default function Ejemplo() {
  return <HeroLinterna cta="Ejemplo" />;
}

Propiedades de HeroLinterna

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

PropiedadTipoDescripción
before?{ eyebrow?: string; title: string; text: string }Opcional.
after?{ title: string; text: string; chips: string[] }Opcional.
href?stringOpcional.
cta?stringOpcional.
radius?numberOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Hero de linterna que revela otra capa 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
--gchlt-r190px

Accesibilidad y rendimiento

El archivo tiene 85 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Hero de linterna que revela otra capa 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-*.
  • Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
  • Usa @property de CSS para animar valores que normalmente no se interpolan.
  • 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)