Galería horizontal guiada por scroll
El escenario queda fijo y la fila de paneles se desplaza de lado según el avance vertical, con barra de progreso.
Lo que construimos
Operación
Procesos que corren solos.
Datos
Una sola fuente de verdad.
Integración
Tus sistemas, conversando.
Seguridad
Trazabilidad desde el primer día.
Soporte
Acompañamiento continuo.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
/** Galería horizontal guiada por el scroll vertical: el escenario queda fijo y la fila de paneles se desplaza de lado según el avance, con una barra de progreso. Autocontenida en su panel. React + TypeScript, sin dependencias. */
export type ScrollHorizontalPanel = { title: string; text: string; hue: number };
export type ScrollHorizontalProps = {
panels?: ScrollHorizontalPanel[];
height?: number;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcsho-root { box-sizing: border-box; position: relative; width: 100%; height: var(--h, 460px); overflow-y: auto; overflow-x: hidden; background: #101216; color: #f1efe8; font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; scrollbar-width: thin; scrollbar-color: #2b2f3a transparent; outline: 0; }
.gcsho-root *, .gcsho-root *::before, .gcsho-root *::after { box-sizing: border-box; }
.gcsho-root:focus-visible { box-shadow: inset 0 0 0 2px #ff8a5c; }
.gcsho-track { height: calc(var(--h, 460px) * (var(--n) + 0.6)); position: relative; }
.gcsho-stage { position: sticky; top: 0; height: var(--h, 460px); overflow: hidden; display: grid; align-content: center; gap: 18px; }
.gcsho-row { display: flex; gap: 18px; padding: 0 clamp(18px, 5vw, 48px); width: max-content; transform: translateX(calc(var(--x, 0) * -1px)); will-change: transform; }
.gcsho-panel { flex: none; display: grid; align-content: end; gap: 6px; width: min(340px, 72vw); height: calc(var(--h, 460px) * .62); padding: 22px; border-radius: 22px; background: linear-gradient(160deg, hsl(var(--hue) 70% 58%), hsl(calc(var(--hue) + 40) 60% 28%)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25); }
.gcsho-panel h3 { margin: 0; font: 700 26px/1.05 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.03em; }
.gcsho-panel p { margin: 0; opacity: .85; font-size: 14px; }
.gcsho-head { padding: 0 clamp(18px, 5vw, 48px); font: 500 clamp(24px, 3.4vw, 38px)/1.05 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.02em; }
.gcsho-bar { position: absolute; left: clamp(18px, 5vw, 48px); right: clamp(18px, 5vw, 48px); bottom: 16px; height: 3px; border-radius: 2px; background: #2b2f3a; }
.gcsho-bar i { display: block; height: 100%; border-radius: 2px; background: #ff8a5c; transform-origin: left; transform: scaleX(var(--p, 0)); }`;
const DEFAULT: ScrollHorizontalPanel[] = [
{ title: "Operación", text: "Procesos que corren solos.", hue: 14 },
{ title: "Datos", text: "Una sola fuente de verdad.", hue: 210 },
{ title: "Integración", text: "Tus sistemas, conversando.", hue: 150 },
{ title: "Seguridad", text: "Trazabilidad desde el primer día.", hue: 280 },
{ title: "Soporte", text: "Acompañamiento continuo.", hue: 40 },
];
export function ScrollHorizontal({ panels = DEFAULT, height = 460, className = "", style }: ScrollHorizontalProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
let raf = 0;
const update = () => {
raf = 0;
const max = el.scrollHeight - el.clientHeight;
const p = max > 0 ? el.scrollTop / max : 0;
const row = el.querySelector<HTMLElement>(".gcsho-row");
const dist = row ? Math.max(0, row.scrollWidth - el.clientWidth) : 0;
el.style.setProperty("--x", String(p * dist));
el.style.setProperty("--p", String(p));
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
update();
el.addEventListener("scroll", onScroll, { passive: true });
const ro = new ResizeObserver(onScroll);
ro.observe(el);
return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); cancelAnimationFrame(raf); };
}, [panels.length]);
return (
<div ref={ref} className={`gcsho-root ${className}`} style={{ "--h": `${height}px`, "--n": panels.length, ...style } as CSSProperties} tabIndex={0} role="region" aria-label="Galería horizontal: desplázate verticalmente">
<style>{CSS}</style>
<div className="gcsho-track">
<div className="gcsho-stage">
<h2 className="gcsho-head">Lo que construimos</h2>
<div className="gcsho-row">
{panels.map((panel) => (
<article key={panel.title} className="gcsho-panel" style={{ "--hue": panel.hue } as CSSProperties}>
<h3>{panel.title}</h3>
<p>{panel.text}</p>
</article>
))}
</div>
<div className="gcsho-bar" aria-hidden="true"><i /></div>
</div>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/scroll-horizontalEjemplo de uso de Galería horizontal guiada por scroll
Después de copiar scroll-horizontal.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ScrollHorizontal } from "./scroll-horizontal";
export default function Ejemplo() {
return <ScrollHorizontal />;
}Propiedades de ScrollHorizontal
ScrollHorizontal acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
panels? | ScrollHorizontalPanel[] | Opcional. |
height? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Galería horizontal guiada por scroll 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 |
|---|---|
--h | 460px |
--x | 0 |
--p | 0 |
Accesibilidad y rendimiento
El archivo tiene 81 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Galería horizontal guiada por scroll por quien lo usa:
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: region.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Es un componente de cliente de Next.js: incluye la directiva "use client".