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.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/hero-linternaEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
before? | { eyebrow?: string; title: string; text: string } | Opcional. |
after? | { title: string; text: string; chips: string[] } | Opcional. |
href? | string | Opcional. |
cta? | string | Opcional. |
radius? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor por defecto |
|---|---|
--gchlt-r | 190px |
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".