Integration Marquee
Three alternating logo ribbons with a cycling connection-status card.
Bring your everyday tools together.
'use client';
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import './integration-marquee.css';
export interface IntegrationMarqueeItem { id: string; label: string; logo: ReactNode; }
export interface IntegrationConnection { id: string; name: string; detail: string; logo: ReactNode; }
export interface IntegrationMarqueeProps {
rows: IntegrationMarqueeItem[][];
connections?: IntegrationConnection[];
title?: ReactNode;
durations?: number[];
paused?: boolean;
cycleConnections?: boolean;
showControls?: boolean;
className?: string;
}
const defaultDurations = [120,140,130];
/** Three seamless logo ribbons and an illustrative connection-status sequence. */
export function IntegrationMarquee({ rows, connections = [], title = 'Bring your everyday tools together.', durations = defaultDurations, paused = false, cycleConnections = true, showControls = true, className = '' }: IntegrationMarqueeProps) {
const uid = useId();
const root = useRef<HTMLElement>(null);
const [held, setHeld] = useState(false);
const [environment, setEnvironment] = useState({ visible:false, hidden:false, reduced:false });
const [copies,setCopies] = useState<number[]>([]);
const [index, setIndex] = useState(0);
const [phase, setPhase] = useState<'connecting'|'syncing'|'connected'>('connected');
const running = environment.visible && !environment.hidden && !environment.reduced && !paused && !held;
const connection = connections.length ? connections[index % connections.length] : undefined;
useEffect(() => {
const element = root.current;
if (!element) return;
const media = matchMedia('(prefers-reduced-motion: reduce)');
let visible = typeof IntersectionObserver === 'undefined';
const sync = () => setEnvironment({ visible, hidden:document.hidden, reduced:media.matches });
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver(([entry]) => { visible=entry.isIntersecting; sync(); });
observer?.observe(element);
media.addEventListener('change',sync);
document.addEventListener('visibilitychange',sync);
sync();
return () => { observer?.disconnect(); media.removeEventListener('change',sync); document.removeEventListener('visibilitychange',sync); };
},[]);
useEffect(() => {
const element=root.current;
if (!element) return;
const measure=()=>{
const next=Array.from(element.querySelectorAll<HTMLElement>('.ac-integration-marquee__row')).map(row=>{
const group=row.querySelector<HTMLElement>('.ac-integration-marquee__group');
const width=group?.getBoundingClientRect().width??0;
return width>0?Math.max(2,Math.min(128,Math.ceil(row.clientWidth/width)+1)):3;
});
setCopies(previous=>previous.length===next.length&&previous.every((count,index)=>count===next[index])?previous:next);
};
const observer=typeof ResizeObserver==='undefined'?null:new ResizeObserver(measure);
observer?.observe(element);
window.addEventListener('resize',measure);
measure();
return ()=>{observer?.disconnect();window.removeEventListener('resize',measure);};
},[rows]);
useEffect(() => {
if (!running || !cycleConnections || connections.length < 2) return;
const timer = window.setTimeout(() => {
if (phase==='connected') { setIndex(value=>value+1); setPhase('connecting'); }
else setPhase(phase==='connecting'?'syncing':'connected');
},phase==='connected'?5000:1300);
return () => window.clearTimeout(timer);
},[running,cycleConnections,connections.length,index,phase]);
const displayedPhase = environment.reduced || !cycleConnections ? 'connected' : phase;
// Connection-status updates must not rebuild the moving logo strips.
const ribbons = useMemo(() => <div className="ac-integration-marquee__rows">
{rows.filter(row=>row.length>0).map((row,rowIndex)=><div className="ac-integration-marquee__row" key={rowIndex}>
<div className="ac-integration-marquee__track" style={{'--ac-integration-copies':copies[rowIndex]??3,'--ac-integration-duration':`${Number.isFinite(durations[rowIndex])?Math.max(20,Math.min(300,durations[rowIndex])):120}s` } as CSSProperties} data-reverse={rowIndex%2===1}>
{Array.from({length:copies[rowIndex]??3},(_,copy)=>copy).map(copy=><div className="ac-integration-marquee__group" key={copy} aria-hidden={copy>0?true:undefined} role={copy===0?'list':undefined} aria-label={copy===0?`Integration row ${rowIndex+1}`:undefined}>
{row.map(item=><span className="ac-integration-marquee__tile" key={item.id} title={item.label} role={copy===0?'listitem':undefined}><span role={copy===0?'img':undefined} aria-label={copy===0?item.label:undefined}>{item.logo}</span></span>)}
</div>)}
</div>
</div>)}
</div>,[rows,copies,durations]);
return <section ref={root} className={`ac-integration-marquee ${className}`.trim()} aria-labelledby={`${uid}-title`} data-running={running}>
<div className="ac-integration-marquee__header">
<h2 id={`${uid}-title`}>{title}</h2>
{connection && <div className="ac-integration-marquee__connection" aria-label={`${connection.name}: ${displayedPhase}`}>
<span className="ac-integration-marquee__connection-logo" key={connection.id} aria-hidden="true">{connection.logo}</span>
<span className="ac-integration-marquee__connection-copy"><strong>{connection.name}</strong><span title={connection.detail}>{displayedPhase==='connecting'?'Signing in…':displayedPhase==='syncing'?'Syncing your workspace…':connection.detail}</span></span>
<span className="ac-integration-marquee__status" data-connected={displayedPhase==='connected'}><svg viewBox="0 0 16 16" fill="none" aria-hidden="true">{displayedPhase==='connected'?<path d="m3 8 3 3 7-7"/>:<path className="ac-integration-marquee__spinner" d="M13 8a5 5 0 1 1-3-4.58"/>}</svg>{displayedPhase==='connected'?'Connected':displayedPhase==='syncing'?'Syncing':'Connecting'}</span>
</div>}
</div>
{ribbons}
{showControls && <div className="ac-integration-marquee__controls"><button type="button" onClick={()=>setHeld(value=>!value)} disabled={paused||environment.reduced} aria-pressed={held||paused}>{environment.reduced?'Motion reduced':held||paused?'Play motion':'Pause motion'}</button></div>}
</section>;
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/integration-marquee.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { IntegrationMarquee } from '@/components/anticraft/IntegrationMarquee';
export function Example() {
const mail = <img src="/logos/mail.svg" alt="" />;
const chat = <img src="/logos/chat.svg" alt="" />;
return <IntegrationMarquee
rows={[
[{ id: 'mail', label: 'Mail', logo: mail }, { id: 'chat', label: 'Chat', logo: chat }],
[{ id: 'files', label: 'Files', logo: <span>▧</span> }],
[{ id: 'calendar', label: 'Calendar', logo: <span>▦</span> }],
]}
connections={[
{ id: 'mail', name: 'Mail', detail: 'Team inbox connected', logo: mail },
{ id: 'chat', name: 'Chat', detail: 'Workspace connected', logo: chat },
]}
/>;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
rows | IntegrationMarqueeItem[][] | Required | Logo rows with unique item ids, accessible labels and ReactNode logo artwork. Each row repeats decoratively to fill the available width and loop seamlessly. |
connections | IntegrationConnection[] | [] | Optional illustrative status cards with id, name, detail and logo. This visual sequence does not connect real accounts. |
title | ReactNode | Bring your everyday tools together. | Large heading above the logo rows. |
durations | number[] | [120, 140, 130] | Seconds per row cycle, clamped to 20–300. The middle row moves in the opposite direction. |
cycleConnections | boolean | true | Cycles connection examples through signing in, syncing and connected states. False keeps the current card connected. |
paused / showControls | boolean | false / true | External pause override and native pause-control visibility. Reduced motion and hidden/offscreen state stop the sequence. |
className | string | '' | Additional classes. Customize --ac-integration-surface, --ac-integration-ink, --ac-integration-muted, --ac-integration-line, --ac-integration-tile and --ac-integration-font. |
Motion & accessibility
Logo rows translate continuously at three different speeds. A stationary overlay fades the edges without masking the moving tracks. Duplicate artwork is hidden from assistive technology. Pause and reduced motion stop both ribbons and the illustrative status sequence. Offscreen and hidden-document state suspend motion; resuming keeps ribbon position. The connected status is shown for reduced motion.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
The ruled app grid, logo ribbons and opposing activity columns take their visual structure from this reference. Demo copy is independent; artwork credits are in the repository.
Visit PlyxlThe linked site is the design or content reference. See the repository for demo artwork credits.