Vertical Marquee
Two columns of tweet cards scrolling vertically in opposite directions with a progressive fade at the top and bottom edges.
Installation
File Structure
Usage
import { VerticalMarquee } from "@/components/unlumen-ui/vertical-marquee";
const tweetIds = [
"1892181693611458734",
"1886746617457354918",
"1874122090617197017",
"1854306627573950703",
];
export default async function Page() {
return (
<div className="h-[560px]">
<VerticalMarquee tweetIds={tweetIds} />
</div>
);
}API Reference
VerticalMarquee
tweetIdsstring[]—Array of tweet IDs to fetch and display in the scrolling columns.
columns?1 | 22Number of scrolling columns. Both columns scroll at different speeds and in opposite directions.
speed?number20Duration in seconds for one full scroll loop of the primary column.
gap?number16Vertical gap between tweet cards in pixels. Also used as the horizontal gap between columns.
blurSize?number120Height in pixels of the progressive fade zone at the top and bottom edges.
pauseOnHover?booleantrueWhen true, all columns smoothly decelerate to a stop while the cursor is over the component.
className?string—Extra classes applied to the root container element.
tweetClassName?string—Extra classes applied to each tweet card.
Credits
Built by Léo
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import { getTweet, type Tweet } from "react-tweet/api";
import {
TweetNotFound,
VerticalMarqueeClient,
} from "./vertical-marquee-client";
import type { TweetItem } from "./vertical-marquee.types";
export type { TweetItem } from "./vertical-marquee.types";
export interface VerticalMarqueeProps {
/** Tweet IDs to display in the scrolling columns. */
tweetIds: string[];
/** Number of scrolling columns. @default 2 */
columns?: 1 | 2;
/** Scroll duration in seconds per full loop. @default 20 */
speed?: number;
/** Vertical gap between cards in px. @default 16 */
gap?: number;
/** Height of the fade zone at top and bottom in px. @default 120 */
blurSize?: number;
/** Smoothly decelerate to a stop when hovering. @default true */
pauseOnHover?: boolean;
className?: string;
tweetClassName?: string;
}
async function getTweetItem(id: string): Promise<TweetItem> {
const tweet = await getTweet(id).catch((error) => {
console.error(error);
return undefined;
});
return { id, tweet };
}
export async function VerticalMarquee({
tweetIds,
columns = 2,
speed = 20,
gap = 16,
blurSize = 120,
pauseOnHover = true,
className,
tweetClassName,
}: VerticalMarqueeProps) {
if (!tweetIds.length) {
return null;
}
const tweets = await Promise.all(tweetIds.map((id) => getTweetItem(id)));
const hasResolvedTweet = tweets.some((item) => item.tweet);
if (!hasResolvedTweet) {
return (
<div className="grid gap-4 sm:grid-cols-2">
{tweetIds.slice(0, Math.min(tweetIds.length, 4)).map((id) => (
<TweetNotFound key={id} />
))}
</div>
);
}
return (
<VerticalMarqueeClient
tweets={tweets}
columns={columns}
speed={speed}
gap={gap}
blurSize={blurSize}
pauseOnHover={pauseOnHover}
className={className}
tweetClassName={tweetClassName}
/>
);
}
export {
MagicTweet,
TweetBody,
TweetHeader,
TweetMedia,
TweetNotFound,
TweetSkeleton,
VerticalMarqueeClient,
} from "./vertical-marquee-client";