Tablero de láminas giratorias
Panel de aeropuerto con láminas que giran: cada letra recorre el alfabeto hasta su destino. Skeuomórfico.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/texto-tableroEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
rows? | string[] | Opcional. |
step? | number | Milisegundos por paso de lámina. |
cycle? | number | Cambia de fila cada cierto tiempo (ms). 0 desactiva el ciclo. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor por defecto |
|---|---|
--w | 34px |
--s | 70ms |
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".