Ir al contenido
Evaluar mi proyecto
Componentes · ScrollDisponible

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.

scroll-horizontal.tsx81 líneas
Descargar
"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.

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

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
panels?ScrollHorizontalPanel[]Opcional.
height?numberOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor por defecto
--h460px
--x0
--p0

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)