Ir al contenido
Evaluar mi proyecto
Componentes · Texto y animacionesDisponible

Tablero de láminas giratorias

Panel de aeropuerto con láminas que giran: cada letra recorre el alfabeto hasta su destino. Skeuomórfico.

Demostración interactiva

Código completo

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

texto-tablero.tsx124 líneas
Descargar
"use client";

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

/** Tablero de aeropuerto de láminas giratorias (split-flap): cada letra recorre el alfabeto hasta su destino con el giro de la lámina. Skeuomórfico. React + TypeScript, sin dependencias. */
export type TextoTableroProps = {
  rows?: string[];
  /** Milisegundos por paso de lámina. */
  step?: number;
  /** Cambia de fila cada cierto tiempo (ms). 0 desactiva el ciclo. */
  cycle?: number;
  className?: string;
  style?: CSSProperties;
};

const ALPHABET = " ABCDEFGHIJKLMNÑOPQRSTUVWXYZ0123456789:-.";

const CSS = `
.gctfp-root { box-sizing: border-box; display: inline-grid; gap: 8px; max-width: 100%; padding: 18px; border-radius: 12px; background: #1b1b1d; box-shadow: inset 0 0 0 2px #0a0a0b, inset 0 2px 0 rgba(255, 255, 255, .08), 0 18px 30px -12px rgba(0, 0, 0, .7); overflow-x: auto; }
.gctfp-root *, .gctfp-root *::before, .gctfp-root *::after { box-sizing: border-box; }
.gctfp-row { display: flex; gap: 4px; }
.gctfp-cell { position: relative; flex: none; width: var(--w, 34px); height: calc(var(--w, 34px) * 1.45); perspective: 400px; font: 700 calc(var(--w, 34px) * .95)/1 "Helvetica Neue", Arial, ui-sans-serif, sans-serif; color: #f2efe4; }
.gctfp-half { position: absolute; left: 0; right: 0; overflow: hidden; background: linear-gradient(180deg, #2c2c30, #222225); display: grid; }
.gctfp-top { top: 0; height: 50%; border-radius: 4px 4px 0 0; align-items: start; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1); }
.gctfp-bot { bottom: 0; height: 50%; border-radius: 0 0 4px 4px; align-items: end; background: linear-gradient(180deg, #1f1f22, #28282c); }
.gctfp-half > span { position: absolute; left: 0; right: 0; display: block; height: calc(var(--w, 34px) * 1.45); line-height: calc(var(--w, 34px) * 1.45); text-align: center; }
.gctfp-top > span { top: 0; }
.gctfp-bot > span { bottom: 0; }
.gctfp-flap { transform-origin: 50% 100%; backface-visibility: hidden; z-index: 2; animation: gctfp-down var(--s, 70ms) ease-in forwards; }
.gctfp-flap2 { top: 50%; bottom: auto; height: 50%; transform-origin: 50% 0; transform: rotateX(90deg); backface-visibility: hidden; z-index: 2; border-radius: 0 0 4px 4px; animation: gctfp-up var(--s, 70ms) ease-out var(--s, 70ms) forwards; }
.gctfp-cell::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 2px; margin-top: -1px; background: #0a0a0b; z-index: 3; }
@keyframes gctfp-down { to { transform: rotateX(-90deg); } }
@keyframes gctfp-up { to { transform: rotateX(0); } }
@media (prefers-reduced-motion: reduce) { .gctfp-flap, .gctfp-flap2 { animation: none; display: none; } }`;

const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function useReduced() {
  return useSyncExternalStore(
    (notify) => {
      const query = window.matchMedia(REDUCED_QUERY);
      query.addEventListener("change", notify);
      return () => query.removeEventListener("change", notify);
    },
    () => window.matchMedia(REDUCED_QUERY).matches,
    () => false,
  );
}


function Cell({ target, step, delay }: { target: string; step: number; delay: number }) {
  const reduced = useReduced();
  const [shown0, setShown] = useState(" ");
  const [prev0, setPrev] = useState(" ");
  const [tick, setTick] = useState(0);
  const [flip, setFlip] = useState(false);
  const cur = useRef(" ");
  const shown = reduced ? target.toUpperCase() : shown0;
  const prev = reduced ? target.toUpperCase() : prev0;

  useEffect(() => {
    let alive = true;
    let timer = 0;
    let settle = 0;
    const goal = target.toUpperCase();
    if (reduced) return;
    const run = () => {
      if (!alive || cur.current === goal) return;
      const from = cur.current;
      const idx = ALPHABET.indexOf(from);
      const next = ALPHABET[(idx + 1) % ALPHABET.length];
      cur.current = next;
      setPrev(from);
      setShown(next);
      setTick((value) => value + 1);
      setFlip(true);
      window.clearTimeout(settle);
      settle = window.setTimeout(() => setFlip(false), step * 2 + 40);
      timer = window.setTimeout(run, step * 2);
    };
    timer = window.setTimeout(run, delay);
    return () => {
      alive = false;
      window.clearTimeout(timer);
      window.clearTimeout(settle);
    };
  }, [target, step, delay, reduced]);

  return (
    <span className="gctfp-cell" aria-hidden="true">
      <span className="gctfp-half gctfp-top"><span>{shown}</span></span>
      <span className="gctfp-half gctfp-bot"><span>{flip ? prev : shown}</span></span>
      {tick > 0 && flip ? (
        <>
          <span key={`a${tick}`} className="gctfp-half gctfp-top gctfp-flap"><span>{prev}</span></span>
          <span key={`b${tick}`} className="gctfp-half gctfp-bot gctfp-flap2"><span>{shown}</span></span>
        </>
      ) : null}
    </span>
  );
}

export function TextoTablero({ rows = ["MEXICO CDMX 08:45", "MONTERREY  09:10", "GUADALAJARA 09:30"], step = 70, cycle = 0, className = "", style }: TextoTableroProps) {
  const [index, setIndex] = useState(0);
  useEffect(() => {
    if (!cycle) return;
    const id = window.setInterval(() => setIndex((value) => (value + 1) % rows.length), cycle);
    return () => window.clearInterval(id);
  }, [cycle, rows.length]);
  const width = Math.max(...rows.map((row) => row.length));
  const list = cycle ? [rows[index]] : rows;
  return (
    <div className={`gctfp-root ${className}`} style={{ "--s": `${step}ms`, ...style } as CSSProperties} role="img" aria-label={list.join(", ")}>
      <style>{CSS}</style>
      {list.map((row, r) => (
        <div key={r} className="gctfp-row">
          {Array.from(row.padEnd(width, " ")).map((char, i) => (
            <Cell key={i} target={char} step={step} delay={i * 40 + r * 120} />
          ))}
        </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/texto-tablero

Ejemplo de uso de Tablero de láminas giratorias

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

ejemplo.tsx5 líneas
import { TextoTablero } from "./texto-tablero";

export default function Ejemplo() {
  return <TextoTablero />;
}

Propiedades de TextoTablero

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

PropiedadTipoDescripción
rows?string[]Opcional.
step?numberMilisegundos por paso de lámina.
cycle?numberCambia de fila cada cierto tiempo (ms). 0 desactiva el ciclo.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Tablero de láminas giratorias 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
--w34px
--s70ms

Accesibilidad y rendimiento

El archivo tiene 124 líneas (6 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Tablero de láminas giratorias por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Declara roles ARIA para lectores de pantalla: img.
  • Lee preferencias del navegador con useSyncExternalStore, sin parpadeos en la hidratación.
  • 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)