Marquee
Content scrolls continuously along a track without interaction. Logo strips and social proof.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Logo strip:
```tsx import { Marquee, MarqueeItem, MarqueeSeparator } from '@/components/atoms/Marquee';
<Marquee speed={75} fade pauseOnHover> <MarqueeItem>Acme</MarqueeItem> <MarqueeSeparator /> <MarqueeItem>Globex</MarqueeItem> </Marquee> ```
## Accesibilidad
- CSS mode duplicates the list (`aria-hidden` on the clone); keep meaningful text only in the first list. - Prefer `pauseOnHover` when items are links users must click. Avoid autoplay motion that cannot be paused if it causes distraction (respect reduced-motion at the product layer when required).
## Cuándo no usar
- Static logo grids that should all be visible at once → layout grid, not a marquee. - Draggable mode needs the motion package hooks — do not enable `draggable` without them.
## Criterio de uso
- Usa Marquee para prueba social o continuidad visual (logos, testimonios cortos), nunca para contenido que el usuario deba leer completo. - `pauseOnHover` es obligatorio si el contenido es legible: sin pausa, leer implica perseguir el texto. - `fade` sugiere continuidad en los bordes; sin el, la cinta parece cortada.
## Gotchas
- La animacion base es CSS: funciona sin GSAP. `draggable` es lo unico que exige el modulo de animaciones. - `speed` deriva la duracion del ancho real del contenido: duplicar los items cambia la velocidad percibida si no ajustas.
Uso
import { Marquee, MarqueeItem, MarqueeSeparator } from '@/components/atoms/Marquee';
<Marquee speed={75} fade pauseOnHover>
<MarqueeItem>Acme</MarqueeItem>
<MarqueeSeparator />
<MarqueeItem>Globex</MarqueeItem>
</Marquee>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| speed | number | 75 | 20–200 step 5 px/s | CSS-mode scroll speed used to derive animation duration from content width. | 75 for logo bars; 40–60 for dense text; 100–150 for punchy hero strips. Default: 75. Ignored when draggable. |
| reverse | boolean | false | `true` / `false` | Reverses travel direction. | true for opposing dual marquees. Default: false. |
| pauseOnHover | boolean | false | `true` / `false` | Pauses CSS animation while hovered. | true when items are links users must click. Default: false. |
| fade | boolean | true | `true` / `false` | Edge fade masks on the track. | true for polished marketing; false when the track is full-bleed. Default: true. |
| draggable | boolean | false | `true` / `false` | Switches to GSAP draggable marquee contract instead of CSS loop. | true only when the animations package + data-draggable-marquee hooks are installed. Default: false. |
| duration | number | — | 5–60 step 1 s | Draggable-mode loop duration (data-duration). | 15–25 for logos; higher = slower. Source default is 20 when draggable. Only when draggable. |
| multiplier | number | 35 | 10–80 step 1 unit | Draggable inertia multiplier (data-multiplier). | 30–40 default feel; lower for tighter control. Default: 35. Only when draggable. |
| sensitivity | number | 0.01 | 0.001–0.1 step 0.001 unit | Draggable drag sensitivity (data-sensitivity). | 0.01 default; raise slightly for touch-first UIs. Default: 0.01. Only when draggable. |
Gotchas
- react
CSS mode duplicates children for seamless loop. Wrap each logo/line in MarqueeItem; use MarqueeSeparator between marks.
- a11y
Duplicated list is aria-hidden; keep meaningful text in the first list only. Prefer pauseOnHover when content is interactive.
- react
draggable requires the motion package hooks that read data-draggable-marquee attributes — without them the track will not move.
Anatomía CSS
<div class="ds-marquee"> <span class="ds-marquee__logo"></span> </div>
| Clase | Propósito |
|---|---|
ds-marquee__logo | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | fg | #0a0a0a | foreground |
| all | bg | #ffffff | card |
| all | border | 1.5px | stroke.thin |
| all | hover-bg | #f5f5f5 | accent |
| all | hover-border | #171717 | border.focus |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--duration-200) | var(--easing-out) |
border-color | var(--duration-200) | var(--easing-out) |
@keyframes marquee-scroll | | |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Marquee
Infinite horizontal scroll. CSS animation, JS for speed + duplication.
Pauses when out of viewport. Hover pauses optional.
Parts: .marquee, .marquee__list, .marquee__item
------------------------------------------------------------------------- */
/* ---- Container ---- */
.marquee {
display: flex;
position: relative;
overflow: hidden;
width: 100%;
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
}
/* ---- Fade edges ---- */
.marquee--fade {
mask-image: linear-gradient(
to right,
transparent,
black 40px,
black calc(100% - 40px),
transparent
);
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 40px,
black calc(100% - 40px),
transparent
);
}
/* ---- List (duplicated by JS) ---- */
/* stretch y no center: los items miden lo mismo que el mas alto de la fila, y
cada uno decide dentro como coloca su contenido (.marquee__item sigue
centrando el suyo, asi que una tira de logos no cambia). Es lo que permite
que una fila de cards se lea pareja aunque una lleve mas texto. */
.marquee__list {
flex: none;
display: flex;
align-items: stretch;
animation: marquee-scroll 30s linear infinite;
animation-play-state: running;
}
.marquee--reverse .marquee__list {
animation-direction: reverse;
}
.marquee--pause-hover:hover .marquee__list {
animation-play-state: paused;
}
@keyframes marquee-scroll {
to {
transform: translateX(-100%);
}
}
/* ---- Item ---- */
.marquee__item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
flex-shrink: 0;
white-space: nowrap;
}
/* ---- Logos mode ----
Tira de marcas de cliente. Los logos llegan con proporciones y pesos opticos
dispares, asi que la unica medida que se puede igualar por CSS es la ALTURA;
lo demas es correccion caso por caso.
--marquee-logo-scale existe justo para eso. La correccion optica que vivia en
prod la hacia un embed con selectores por nombre de archivo
(img[src*="toyota"] { transform: scale(.76) }): se rompia al renombrar el
asset y era invisible desde el panel. Como variable por item, la pone quien
edita y sobrevive al asset.
OJO en Webflow: el panel del Designer no declara custom properties y rechaza
el atributo style, asi que ahi la correccion va como combo class por marca
con la altura ya multiplicada (.ds-marquee__logo.ds-logo--toyota
{ height: 2.66rem }), que gana por especificidad. La variable sigue siendo el
contrato para React y CLI. */
.marquee__logo {
display: block;
/* La correccion optica multiplica la ALTURA, no un transform: scale().
Un transform no cambia la caja: el logo corregido al alza se sale de su
item y se monta sobre el siguiente, y el hueco corregido a la baja queda
de mas. Se veia sobre todo al reiniciar el ciclo, porque ahi coinciden el
ultimo item de una lista y el primero de la siguiente. Con height la caja
crece o encoge con el logo y el ritmo del carril vuelve a ser real. */
height: calc(3rem * 1);
width: auto;
/* Webflow sirve img { max-width: 100% } de fabrica: sin esto, un wordmark
ancho se comprime en vez de respetar su altura. */
max-width: none;
object-fit: contain;
}
/* El espaciado sigue el modelo del marquee de Osmo: el padding del item va a UN
SOLO lado. Con padding en los dos, el hueco entre logos es el DOBLE del valor
declarado y el primer logo entra despegado del borde de la seccion; con un
solo lado, el valor declarado ES el hueco y la tira arranca a ras.
El default de .marquee__item (12/16px) sirve para texto, no para marcas:
dos logos separados por 32px se leen como un logo compuesto.
Va como modificador del ITEM y no como modo del carril (.marquee--logos ...)
por el canal de Webflow: el panel del Designer no declara selectores
descendentes, asi que un modo en el contenedor no se puede reproducir ahi y
la tira quedaria sin espaciado en el canvas. Como combo class del item, si.
Sin separador entre logos: se probo un punto de acento y en una tira de
marcas ajenas mete un tercer color que compite con cada logo. El ritmo lo
marca el hueco. */
.marquee__item--logo {
padding-block: 16px;
padding-inline: 0 var(--spacing-10);
}
/* Insignia: mas alta que un logo de cliente porque no es una marca, es un sello
—G2, Meta, HubSpot— y lleva texto dentro que a 3.5rem no se lee.
Va como modificador y no como --marquee-logo-height en el consumidor porque
el panel de Webflow no declara custom properties: alli una variable no se
puede pasar, un combo class si. */
.marquee__logo--badge {
height: 5rem;
}
/* ---- Separator (optional decorative element between items) ---- */
.marquee__separator {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #525252;
}
.marquee__separator svg {
width: 1em;
height: 1em;
}
/* ---- Draggable mode (GSAP) ---- */
/* En modo draggable el desplazamiento lo pone GSAP sobre __collection. La
animacion CSS de __list seguiria corriendo sobre el MISMO eje y sumaria un
segundo movimiento: la tira avanza al doble y el wrap de GSAP deja de cuadrar
con el ancho real. Se apaga aqui, no en el behavior, porque es pintura. */
.marquee--draggable .marquee__list {
animation: none;
}
.marquee__collection {
display: flex;
align-items: stretch;
flex: none;
will-change: transform;
cursor: grab;
}
.marquee__collection:active {
cursor: grabbing;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.marquee__list {
animation-play-state: paused;
}
}
/* ---- Controles prev/next ----
Van FUERA de .marquee, no dentro: .marquee es el carril de recorte
(overflow: hidden) y meterlos ahi los ataria al alto de la tira. Como fila
propia debajo, el recorte sigue siendo solo del carril.
El loop es infinito, asi que no hay estado deshabilitado: no existe un primer
ni un ultimo item que alcanzar. Un carrusel acotado si lo necesitaria. */
.marquee__controls {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-block-start: 24px;
}
.marquee__control {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: 1.5px solid #e5e5e5;
border-radius: 9999px;
background-color: #ffffff;
color: #0a0a0a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.marquee__control:hover {
background-color: #f5f5f5;
border-color: #171717;
}
.marquee__control:focus-visible {
outline: 2px solid #0a0a0a;
outline-offset: 1.5px;
}
.marquee__control svg {
width: 1em;
height: 1em;
}
@media (prefers-reduced-motion: reduce) {
.marquee__control {
transition-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, useRef, useEffect, useState, Children, cloneElement, isValidElement, } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } export type MarqueeProps = { speed?: number; reverse?: boolean; pauseOnHover?: boolean; fade?: boolean; draggable?: boolean; duration?: number; multiplier?: number; sensitivity?: number; /** Solo en draggable: false deja la tira quieta hasta que la arrastren. */ autoplay?: boolean; /** Solo en draggable: arrastre-retardo de los items. 0 = sin retardo. */ lag?: number; /** Solo en draggable sin autoplay: imanta a limite de item al soltar. */ snap?: 'true' | 'auto' | 'false'; /** Solo en draggable: flechas prev/next a los costados. */ controls?: boolean; /** Etiquetas accesibles de las flechas. El DS no las inyecta: este sitio corre en tres idiomas y una etiqueta fija seria incorrecta en dos. */ prevLabel?: string; nextLabel?: string; children: ReactNode; className?: string; }; export function Marquee({ speed = 75, reverse = false, pauseOnHover = false, fade = true, draggable = false, duration: draggableDuration = 20, multiplier = 35, sensitivity = 0.01, autoplay = true, lag = 0, snap = 'false', controls = false, prevLabel = 'Previous', nextLabel = 'Next', children, className, }: MarqueeProps) { const containerRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null); const [cssDuration, setCssDuration] = useState('30s'); // CSS mode: calculate duration based on content width useEffect(() => { if (draggable || !listRef.current) return; const width = listRef.current.offsetWidth; setCssDuration(`${width / speed}s`); }, [speed, children, draggable]); // CSS mode: IntersectionObserver pause useEffect(() => { if (draggable || !containerRef.current) return; const el = containerRef.current; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { el.style.setProperty('--marquee-state', entry.isIntersecting ? 'running' : 'paused'); }); }, { threshold: 0 }, ); observer.observe(el); return () => observer.disconnect(); }, [draggable]); const listContent = Children.toArray(children); // Draggable mode: GSAP animation via data-attribute contract. // marquee--draggable no es decorativo: apaga la animacion CSS de __list. En // este modo el desplazamiento lo pone GSAP sobre __collection, y con las dos // vivas la tira avanza al doble sobre el mismo eje. if (draggable) { return ( <> <div ref={containerRef} data-draggable-marquee="" data-direction={reverse ? 'right' : 'left'} data-duration={draggableDuration} data-multiplier={multiplier} data-sensitivity={sensitivity} data-autoplay={autoplay ? 'true' : 'false'} data-lag={lag} data-snap={snap} className={cn('marquee', 'marquee--draggable', fade && 'marquee--fade', className)} > <div data-draggable-marquee-collection="" className="marquee__collection"> <div data-draggable-marquee-list="" className="marquee__list"> {listContent} </div> </div> </div> {controls && ( <div className="marquee__controls"> <button type="button" className="marquee__control marquee__control--prev" data-draggable-marquee-control="prev" aria-label={prevLabel} /> <button type="button" className="marquee__control marquee__control--next" data-draggable-marquee-control="next" aria-label={nextLabel} /> </div> )} </> ); } // CSS mode return ( <div ref={containerRef} className={cn( 'marquee', reverse && 'marquee--reverse', pauseOnHover && 'marquee--pause-hover', fade && 'marquee--fade', className, )} style={{ '--marquee-duration': cssDuration } as React.CSSProperties} > <div ref={listRef} className="marquee__list"> {listContent} </div> <div className="marquee__list" aria-hidden="true"> {listContent} </div> </div> ); } /* ---- Item ---- */ export function MarqueeItem({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('marquee__item', className)}>{children}</div>; } /* ---- Separator ---- */ export function MarqueeSeparator({ children, className }: { children?: ReactNode; className?: string }) { const defaultStar = ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" fill="currentColor"> <path d="M17.6777 32.3223C12.9893 27.6339 6.63041 25 0 25C6.63041 25 12.9893 22.3661 17.6777 17.6777C22.3661 12.9893 25 6.63041 25 0C25 6.63041 27.6339 12.9893 32.3223 17.6777C37.0107 22.3661 43.3696 25 50 25C43.3696 25 37.0107 27.6339 32.3223 32.3223C27.6339 37.0107 25 43.3696 25 50C25 43.3696 22.3661 37.0107 17.6777 32.3223Z" /> </svg> ); return ( <span className={cn('marquee__separator', className)}> {children || defaultStar} </span> ); }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Marquee) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.
Setup del sitio (una vez)
Custom Code → Head:
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/webflow.css">
Custom Code → Footer (before </body>):
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Observer.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script> <script src="https://atom-web-ds.vercel.app/v1/animations.js"></script> <script>window.__atomMotionCleanup = AtomMotion.initAll();</script>
DOM contract — no quites estos atributos
data-draggable-marqueedata-draggable-marquee-collectiondata-draggable-marquee-listdata-autoplaydata-lagdata-snapdata-draggable-marquee-control
Anatomía: [data-draggable-marquee-collection][data-draggable-marquee-list].marquee__item
Unsupported (no silencioso)
:focus-visibleon.marquee__control:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.marquee--reverse .marquee__list— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.marquee--pause-hover:hover .marquee__list— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.marquee__separator svg— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.marquee--draggable .marquee__list— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.marquee__control svg— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.marquee__control:focus-visible— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)@mediaon(prefers-reduced-motion: reduce)— not a Designer breakpoint — moved to head Custom Code block
Tras pegar: Create component → nombre Atom / Marquee → Publish.