Text Stream
Text Stream
A continuous vertical text stream changes speed and direction with your scroll.
Scroll the page to change the stream’s momentum.
Component preview
Text Stream
Install using CLI
npx shadcn@latest add "https://www.obsidianui.dev/r/text-stream.json"Usage
Code
"use client";
import { TextStream } from "@/components/block/text-stream";
export default function Demo() {
return <TextStream items={["Create", "Explore", "Build", "Ship"]} prefix="Let’s" height="400px" fontSize="2.25rem" />;
}Install Manually
1
Install dependencies
npm install clsx gsap tailwind-merge
2
Copy the source code
Copy into components/block/text-stream.jsx
"use client";
import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
import "./text-stream.css";
/**
* @param {{
* items?: string[], prefix?: string, fontSize?: string, fontWeight?: number,
* height?: string | number, paused?: boolean, className?: string,
* style?: import('react').CSSProperties,
* scroller?: HTMLElement | Window | import('react').RefObject<HTMLElement | null>
* }} props
*/
export function TextStream({
items = [],
prefix = "ObsidianUI",
fontSize = "clamp(1.25rem, 3vw, 2.25rem)",
fontWeight = 200,
height = "100vh",
scroller,
paused = false,
className,
style,
}) {
const trackRef = useRef(null);
const contentRef = useRef(null);
const containerRef = useRef(null);
const [copyCount, setCopyCount] = useState(2);
const metricsRef = useRef({
currentY: 0,
distance: 0,
currentVelocity: 0.6,
targetVelocity: 0.6,
lastScrollDirection: 1,
});
const scrollTimeoutRef = useRef(null);
useEffect(() => {
const track = trackRef.current;
const content = contentRef.current;
const container = containerRef.current;
if (!track || !content || !container || paused || !items.length) return;
const media = gsap.matchMedia();
media.add("(prefers-reduced-motion: no-preference)", () => {
const baseSpeed = 0.6;
const maxBoost = 12;
const scrollTarget = (scroller?.current !== undefined ? scroller.current : scroller) ?? window;
const readScroll = () => scrollTarget === window ? window.scrollY : scrollTarget.scrollTop;
let lastScrollY = readScroll();
const startAnimation = () => {
const distance = content.offsetHeight;
const containerHeight = container.offsetHeight;
if (!distance || !containerHeight) return;
const nextCopyCount = Math.max(2, Math.ceil(containerHeight / distance) + 2);
setCopyCount((c) => (c === nextCopyCount ? c : nextCopyCount));
metricsRef.current.distance = distance;
metricsRef.current.currentY = gsap.utils.wrap(-distance, 0, metricsRef.current.currentY);
gsap.set(track, { y: metricsRef.current.currentY });
};
const tick = (_, deltaTime) => {
const { distance } = metricsRef.current;
if (!distance) return;
const frameFactor = deltaTime / (1000 / 60);
metricsRef.current.currentVelocity = gsap.utils.interpolate(
metricsRef.current.currentVelocity,
metricsRef.current.targetVelocity,
0.14
);
metricsRef.current.currentY += metricsRef.current.currentVelocity * frameFactor;
metricsRef.current.currentY = gsap.utils.wrap(-distance, 0, metricsRef.current.currentY);
gsap.set(track, { y: metricsRef.current.currentY });
};
const applyScrollMotion = (delta) => {
if (!delta) return;
const direction = delta > 0 ? -1 : 1;
const boost = Math.min(maxBoost, baseSpeed + Math.pow(Math.abs(delta), 1.2) * 0.08);
metricsRef.current.lastScrollDirection = direction;
metricsRef.current.targetVelocity = direction * boost;
window.clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = window.setTimeout(() => {
metricsRef.current.targetVelocity = metricsRef.current.lastScrollDirection * baseSpeed;
}, 120);
};
const handleWheel = (e) => applyScrollMotion(e.deltaY);
const handleScroll = () => {
const next = readScroll();
applyScrollMotion(next - lastScrollY);
lastScrollY = next;
};
startAnimation();
gsap.ticker.add(tick);
const ro = new ResizeObserver(startAnimation);
ro.observe(content);
ro.observe(container);
window.addEventListener("resize", startAnimation);
scrollTarget.addEventListener("wheel", handleWheel, { passive: true });
scrollTarget.addEventListener("scroll", handleScroll, { passive: true });
return () => {
ro.disconnect();
window.removeEventListener("resize", startAnimation);
scrollTarget.removeEventListener("wheel", handleWheel);
scrollTarget.removeEventListener("scroll", handleScroll);
window.clearTimeout(scrollTimeoutRef.current);
gsap.ticker.remove(tick);
};
}, containerRef);
return () => media.revert();
}, [items, paused, scroller]);
if (!items.length) return null;
return (
<div className={cn("obsidian-text-stream font-heading text-foreground", className)} style={{ display: "flex", height, ...style }}>
{/* Left — static prefix */}
<div style={{ width: "45%", display: "flex", alignItems: "center", justifyContent: "flex-end", paddingRight: 8 }}>
<p style={{ fontSize, fontWeight, lineHeight: 1, whiteSpace: "nowrap", margin: 0 }}>
{prefix}
</p>
</div>
{/* Right — scrolling marquee */}
<div
ref={containerRef}
className="obsidian-text-stream__viewport"
style={{
position: "relative", width: "55%", height: "100%", overflow: "hidden",
maskImage: "linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.2) 48%, rgba(0,0,0,1) 48%, rgba(0,0,0,1) 53%, rgba(0,0,0,0.3) 53%, rgba(0,0,0,0.3) 100%)",
WebkitMaskImage: "linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.2) 48%, rgba(0,0,0,1) 48%, rgba(0,0,0,1) 53%, rgba(0,0,0,0.2) 53%, rgba(0,0,0,0.2) 100%)",
}}
>
<div
ref={trackRef}
className="obsidian-text-stream__track"
style={{ position: "absolute", left: 0, top: 0, display: "flex", flexDirection: "column", fontSize, fontWeight, lineHeight: 1 }}
>
{Array.from({ length: copyCount }, (_, copyIndex) => (
<div
key={copyIndex}
ref={copyIndex === 0 ? contentRef : null}
className="obsidian-text-stream__copy"
style={{ display: "flex", flexDirection: "column" }}
aria-hidden={copyIndex > 0}
>
{items.map((text, i) => (
<div key={`${copyIndex}-${i}`} style={{ padding: "4px 0 4px 8px", whiteSpace: "nowrap" }}>
{text}
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}3
Add the CSS file
Copy into components/block/text-stream.css
.obsidian-text-stream { min-width: 0; }
@media (prefers-reduced-motion: reduce) {
.obsidian-text-stream .obsidian-text-stream__viewport {
display: flex;
align-items: center;
mask-image: none !important;
-webkit-mask-image: none !important;
}
.obsidian-text-stream .obsidian-text-stream__track {
position: relative !important;
transform: none !important;
}
.obsidian-text-stream__copy + .obsidian-text-stream__copy { display: none !important; }
}4
Add the supporting file
Copy into lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}Preview behavior
Scroll the page to change the stream’s momentum. The effect stays inside its container and respects reduced motion preferences.
Last updated on