Skip to content

Commit 21c044d

Browse files
authored
Merge pull request #75 from pras75299/feat/hero-flow-field
feat(registry): add hero-flow-field scroll-driven generative streamline block
2 parents 5872149 + f6474b4 commit 21c044d

18 files changed

Lines changed: 935 additions & 17 deletions
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"use client";
2+
3+
import { useEffect, useRef, type ComponentProps, type ReactNode } from "react";
4+
import { motion, useReducedMotion } from "motion/react";
5+
import { cn } from "@/lib/utils";
6+
7+
export type FlowFieldBackgroundProps = {
8+
className?: string;
9+
/** Seed-grid spacing in pixels (lower = more streamlines, denser field). */
10+
spacing?: number;
11+
/** Stroke color for the streamlines (any CSS color). */
12+
color?: string;
13+
/** Color of the glowing particle at each streamline head. Defaults to `color`. */
14+
headColor?: string;
15+
/** Stroke width of each streamline in pixels. */
16+
lineWidth?: number;
17+
/**
18+
* How strongly the page scroll position bends the flow field. The waving
19+
* motion is driven by `window.scrollY`; raise to make scrolling reshape the
20+
* field faster, lower for a calmer response.
21+
*/
22+
scrollStrength?: number;
23+
/** Idle drift speed so the field stays alive when the page isn't scrolling. */
24+
speed?: number;
25+
};
26+
27+
// Number of segments traced per streamline. Kept low so a full redraw stays
28+
// cheap (seeds × STEPS line segments per frame).
29+
const STEPS = 16;
30+
31+
export function FlowFieldBackground({
32+
className,
33+
spacing = 56,
34+
color = "rgba(125,211,252,0.45)",
35+
headColor,
36+
lineWidth = 1.1,
37+
scrollStrength = 1,
38+
speed = 0.05,
39+
}: FlowFieldBackgroundProps) {
40+
const canvasRef = useRef<HTMLCanvasElement>(null);
41+
const reduced = useReducedMotion();
42+
// Guard against spacing <= 0 / non-finite, which would blow up cols/rows and
43+
// lock the render loop on the main thread.
44+
const safeSpacing = Number.isFinite(spacing) && spacing > 0 ? spacing : 56;
45+
46+
useEffect(() => {
47+
const canvas = canvasRef.current;
48+
if (!canvas) return;
49+
const ctx = canvas.getContext("2d");
50+
if (!ctx) return;
51+
52+
let raf = 0;
53+
let dpr = Math.min(window.devicePixelRatio || 1, 2);
54+
// Smoothed scroll value so abrupt jumps ease into the field.
55+
let scroll = window.scrollY;
56+
let scrollTarget = scroll;
57+
58+
const resize = () => {
59+
const { clientWidth, clientHeight } = canvas;
60+
dpr = Math.min(window.devicePixelRatio || 1, 2);
61+
canvas.width = Math.round(clientWidth * dpr);
62+
canvas.height = Math.round(clientHeight * dpr);
63+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
64+
};
65+
66+
const onScroll = () => {
67+
scrollTarget = window.scrollY;
68+
};
69+
70+
// Flow direction at a point. Summed sines give an organic field with zero
71+
// external noise deps. `phase` is the only animated input — advanced by
72+
// scroll (primary) plus a slow idle drift.
73+
const angleAt = (x: number, y: number, phase: number) => {
74+
const a = Math.sin(x * 0.0022 + phase) + Math.cos(y * 0.0026 - phase * 0.8);
75+
const b = Math.sin((x + y) * 0.0015 + phase * 0.6);
76+
return (a + b) * Math.PI;
77+
};
78+
79+
const head = headColor ?? color;
80+
81+
const render = (phase: number) => {
82+
const w = canvas.clientWidth;
83+
const h = canvas.clientHeight;
84+
ctx.clearRect(0, 0, w, h);
85+
86+
const step = safeSpacing;
87+
const stepLen = step * 0.55;
88+
const cols = Math.ceil(w / step) + 1;
89+
const rows = Math.ceil(h / step) + 1;
90+
91+
ctx.lineWidth = lineWidth;
92+
ctx.lineCap = "round";
93+
94+
for (let r = 0; r < rows; r++) {
95+
for (let c = 0; c < cols; c++) {
96+
// Stagger the seed phase per cell so neighbouring streamlines aren't
97+
// perfectly parallel — reads as a turbulent field, not a comb.
98+
const seedPhase = phase + (r * 0.6 + c * 0.4);
99+
let x = c * step;
100+
let y = r * step;
101+
102+
ctx.strokeStyle = color;
103+
ctx.globalAlpha = 0.7;
104+
ctx.beginPath();
105+
ctx.moveTo(x, y);
106+
for (let s = 0; s < STEPS; s++) {
107+
const ang = angleAt(x, y, seedPhase);
108+
x += Math.cos(ang) * stepLen;
109+
y += Math.sin(ang) * stepLen;
110+
ctx.lineTo(x, y);
111+
}
112+
ctx.stroke();
113+
114+
// Particle at the streamline head — the "particle field" half of the
115+
// effect. Brighter than the trail so the flow direction reads clearly.
116+
ctx.globalAlpha = 0.9;
117+
ctx.fillStyle = head;
118+
ctx.beginPath();
119+
ctx.arc(x, y, lineWidth * 1.4, 0, Math.PI * 2);
120+
ctx.fill();
121+
}
122+
}
123+
ctx.globalAlpha = 1;
124+
};
125+
126+
resize();
127+
const ro = new ResizeObserver(resize);
128+
ro.observe(canvas);
129+
130+
if (reduced) {
131+
// Static field, anchored to the current scroll position. No rAF, no
132+
// scroll-driven motion — honors prefers-reduced-motion.
133+
render(scroll * 0.0015 * scrollStrength);
134+
return () => ro.disconnect();
135+
}
136+
137+
window.addEventListener("scroll", onScroll, { passive: true });
138+
const start = performance.now();
139+
const draw = (now: number) => {
140+
// Ease smoothed scroll toward the latest value.
141+
scroll += (scrollTarget - scroll) * 0.08;
142+
const drift = ((now - start) / 1000) * speed;
143+
const phase = scroll * 0.0015 * scrollStrength + drift;
144+
render(phase);
145+
raf = requestAnimationFrame(draw);
146+
};
147+
raf = requestAnimationFrame(draw);
148+
149+
return () => {
150+
cancelAnimationFrame(raf);
151+
ro.disconnect();
152+
window.removeEventListener("scroll", onScroll);
153+
};
154+
}, [safeSpacing, color, headColor, lineWidth, scrollStrength, speed, reduced]);
155+
156+
return (
157+
<div aria-hidden className={cn("absolute inset-0 overflow-hidden bg-[#06070a]", className)}>
158+
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
159+
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,_transparent_40%,_#06070a_92%)]" />
160+
</div>
161+
);
162+
}
163+
164+
export type FlowFieldHeroProps = Omit<ComponentProps<"section">, "children"> & {
165+
children?: ReactNode;
166+
backgroundProps?: FlowFieldBackgroundProps;
167+
};
168+
169+
export function FlowFieldHero({
170+
children,
171+
className,
172+
backgroundProps,
173+
...rest
174+
}: FlowFieldHeroProps) {
175+
return (
176+
<section
177+
{...rest}
178+
className={cn(
179+
"relative isolate flex min-h-[100svh] w-full items-center justify-center overflow-hidden bg-[#06070a] text-white",
180+
className,
181+
)}
182+
>
183+
<FlowFieldBackground {...backgroundProps} />
184+
<div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center px-6 text-center">
185+
{children ?? <DefaultFlowFieldContent />}
186+
</div>
187+
</section>
188+
);
189+
}
190+
191+
function DefaultFlowFieldContent() {
192+
return (
193+
<>
194+
<motion.span
195+
initial={{ opacity: 0, y: 8 }}
196+
animate={{ opacity: 1, y: 0 }}
197+
transition={{ delay: 0.05, type: "spring", stiffness: 140, damping: 20 }}
198+
className="mb-5 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.2em] text-white/70 backdrop-blur"
199+
>
200+
<span className="h-1.5 w-1.5 rounded-full bg-sky-300/90 shadow-[0_0_10px_2px] shadow-sky-300/40" aria-hidden />
201+
Field · scroll to flow
202+
</motion.span>
203+
<motion.h1
204+
initial={{ opacity: 0, y: 18 }}
205+
animate={{ opacity: 1, y: 0 }}
206+
transition={{ delay: 0.12, type: "spring", stiffness: 120, damping: 18 }}
207+
className="text-balance text-4xl font-semibold tracking-tight sm:text-6xl"
208+
>
209+
Momentum you can see.
210+
</motion.h1>
211+
<motion.p
212+
initial={{ opacity: 0, y: 14 }}
213+
animate={{ opacity: 1, y: 0 }}
214+
transition={{ delay: 0.24, type: "spring", stiffness: 120, damping: 18 }}
215+
className="mt-5 max-w-xl text-pretty text-base text-white/65 sm:text-lg"
216+
>
217+
A canvas flow field of streamlines that bend and wave with the page
218+
scroll position. Scroll-driven, not cursor-driven — no external deps.
219+
</motion.p>
220+
<motion.div
221+
initial={{ opacity: 0, y: 12 }}
222+
animate={{ opacity: 1, y: 0 }}
223+
transition={{ delay: 0.36, type: "spring", stiffness: 120, damping: 18 }}
224+
className="mt-9 flex flex-wrap items-center justify-center gap-3"
225+
>
226+
<button
227+
type="button"
228+
className="group inline-flex h-11 items-center gap-2 rounded-full bg-white px-6 text-sm font-medium text-black transition-transform duration-200 hover:-translate-y-px"
229+
>
230+
<span>Get started</span>
231+
<span aria-hidden className="transition-transform duration-200 group-hover:translate-x-0.5">
232+
233+
</span>
234+
</button>
235+
<button
236+
type="button"
237+
className="inline-flex h-11 items-center gap-2 rounded-full border border-white/15 px-6 font-mono text-xs uppercase tracking-[0.18em] text-white/85 backdrop-blur transition-colors hover:bg-white/5"
238+
>
239+
View source
240+
</button>
241+
</motion.div>
242+
</>
243+
);
244+
}
245+
246+
export default FlowFieldHero;

apps/www/config/components.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
LucideUserCircle2,
3939
LucideWand2,
4040
LucideWaves,
41+
LucideWind,
4142
Terminal,
4243
} from "lucide-react";
4344

@@ -109,6 +110,7 @@ const iconMap = {
109110
LucideUserCircle2,
110111
LucideWand2,
111112
LucideWaves,
113+
LucideWind,
112114
Terminal,
113115
} satisfies Record<string, ElementType>;
114116

@@ -3386,6 +3388,33 @@ const componentDefinitions = [
33863388
}
33873389
],
33883390
"usageCode": "import { TerminalHero } from \"@/components/ui/hero-terminal\";\n\nexport default function Hero() {\n return (\n <TerminalHero\n username=\"you\"\n title=\"zsh — your-project\"\n commands={[\n \"npx shadcn@latest init\",\n \"npm install motion\",\n \"npx shadcn@latest add button card\",\n ]}\n outputs={{\n 0: [\n \"✔ Preflight checks passed.\",\n \"✔ Created components.json\",\n \"✔ Initialized project.\",\n ],\n 1: [\"added 1 package in 2s\"],\n 2: [\"✔ Done. Installed button, card.\"],\n }}\n >\n <span className=\"mb-5 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.18em] text-white/70 backdrop-blur\">\n One command to ship\n </span>\n <h1 className=\"text-balance text-4xl font-semibold tracking-tight sm:text-6xl\">\n Watch your stack build itself.\n </h1>\n <p className=\"mt-5 max-w-xl text-pretty text-base text-white/65 sm:text-lg\">\n Copy-paste components, zero config — production-ready before the cursor\n stops blinking.\n </p>\n </TerminalHero>\n );\n}"
3391+
},
3392+
{
3393+
"slug": "hero-flow-field",
3394+
"name": "Flow Field Hero",
3395+
"description": "A canvas-rendered hero block. A grid of streamlines traces a summed-sine flow field and bends as you scroll — the field's phase is driven by `window.scrollY`, with a glowing particle at each streamline head. Scroll-driven (not cursor-driven), devicePixelRatio-aware, honors `prefers-reduced-motion` with a static frame, and ships with zero external animation deps beyond `motion`.",
3396+
"icon": "LucideWind",
3397+
"category": "Hero",
3398+
"kind": "block",
3399+
"addedAt": "2026-06-01",
3400+
"props": [
3401+
{
3402+
"name": "children",
3403+
"type": "ReactNode",
3404+
"description": "Slotted hero content. Omit for the default headline + CTAs."
3405+
},
3406+
{
3407+
"name": "className",
3408+
"type": "string",
3409+
"description": "Classes for the outer `<section>`."
3410+
},
3411+
{
3412+
"name": "backgroundProps",
3413+
"type": "{ spacing?: number; color?: string; headColor?: string; lineWidth?: number; scrollStrength?: number; speed?: number; className?: string }",
3414+
"description": "Forwarded to the canvas background layer (`FlowFieldBackground`)."
3415+
}
3416+
],
3417+
"usageCode": "import { FlowFieldHero } from \"@/components/ui/hero-flow-field\";\n\nexport default function Hero() {\n return (\n <FlowFieldHero\n backgroundProps={{\n spacing: 56,\n color: \"rgba(125,211,252,0.45)\",\n scrollStrength: 1,\n }}\n >\n <span className=\"mb-5 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.2em] text-white/70 backdrop-blur\">\n <span\n aria-hidden\n className=\"h-1.5 w-1.5 rounded-full bg-sky-300/90 shadow-[0_0_10px_2px] shadow-sky-300/40\"\n />\n Field · scroll to flow\n </span>\n <h1 className=\"text-balance text-4xl font-semibold tracking-tight sm:text-6xl\">\n Momentum you can see.\n </h1>\n <p className=\"mt-5 max-w-xl text-pretty text-base text-white/65 sm:text-lg\">\n A canvas flow field of streamlines that bend and wave with the page\n scroll position. Scroll-driven, not cursor-driven — no external deps.\n </p>\n <div className=\"mt-9 flex flex-wrap items-center justify-center gap-3\">\n <button\n type=\"button\"\n className=\"group inline-flex h-11 items-center gap-2 rounded-full bg-white px-6 text-sm font-medium text-black transition-transform duration-200 hover:-translate-y-px\"\n >\n <span>Get started</span>\n <span aria-hidden className=\"transition-transform duration-200 group-hover:translate-x-0.5\">\n →\n </span>\n </button>\n <button\n type=\"button\"\n className=\"inline-flex h-11 items-center gap-2 rounded-full border border-white/15 px-6 font-mono text-xs uppercase tracking-[0.18em] text-white/85 backdrop-blur transition-colors hover:bg-white/5\"\n >\n View source\n </button>\n </div>\n </FlowFieldHero>\n );\n}"
33893418
}
33903419
] satisfies ComponentDefinition[];
33913420

apps/www/config/demos.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { NoiseDotFieldHero } from "@/components/ui/hero-noise-dot-field";
7878
import { LogoMarqueeHero } from "@/components/ui/hero-logo-marquee";
7979
import { MagneticLettersHero } from "@/components/ui/hero-magnetic-letters";
8080
import { TerminalHero } from "@/components/ui/hero-terminal";
81+
import { FlowFieldHero } from "@/components/ui/hero-flow-field";
8182
import { motion } from "motion/react";
8283
import { useRef, useState } from "react";
8384
import {
@@ -3830,4 +3831,45 @@ export const componentDemos: Record<string, DemoComponent> = {
38303831
),
38313832
"hero-magnetic-letters": () => <MagneticLettersHero />,
38323833
"hero-terminal": () => <TerminalHero />,
3834+
"hero-flow-field": () => (
3835+
<FlowFieldHero
3836+
backgroundProps={{
3837+
spacing: 56,
3838+
color: "rgba(125,211,252,0.45)",
3839+
scrollStrength: 1,
3840+
}}
3841+
>
3842+
<span className="mb-5 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.2em] text-white/70 backdrop-blur">
3843+
<span
3844+
aria-hidden
3845+
className="h-1.5 w-1.5 rounded-full bg-sky-300/90 shadow-[0_0_10px_2px] shadow-sky-300/40"
3846+
/>
3847+
Field · scroll to flow
3848+
</span>
3849+
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-6xl">
3850+
Momentum you can see.
3851+
</h1>
3852+
<p className="mt-5 max-w-xl text-pretty text-base text-white/65 sm:text-lg">
3853+
A canvas flow field of streamlines that bend and wave with the page
3854+
scroll position. Scroll-driven, not cursor-driven — no external deps.
3855+
</p>
3856+
<div className="mt-9 flex flex-wrap items-center justify-center gap-3">
3857+
<button
3858+
type="button"
3859+
className="group inline-flex h-11 items-center gap-2 rounded-full bg-white px-6 text-sm font-medium text-black transition-transform duration-200 hover:-translate-y-px"
3860+
>
3861+
<span>Get started</span>
3862+
<span aria-hidden className="transition-transform duration-200 group-hover:translate-x-0.5">
3863+
3864+
</span>
3865+
</button>
3866+
<button
3867+
type="button"
3868+
className="inline-flex h-11 items-center gap-2 rounded-full border border-white/15 px-6 font-mono text-xs uppercase tracking-[0.18em] text-white/85 backdrop-blur transition-colors hover:bg-white/5"
3869+
>
3870+
View source
3871+
</button>
3872+
</div>
3873+
</FlowFieldHero>
3874+
),
38333875
};

apps/www/config/docs-scenarios.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,5 +1121,10 @@ export const docsScenarios: Record<string, ComponentDocs> = {
11211121
"slug": "hero-terminal",
11221122
"overview": "TerminalHero pairs a headline with a live terminal that types your getting-started commands and prints their output — a high-conversion 'see it work' pattern for landing pages. The standalone `Terminal` window is exported from the same file for reuse elsewhere. The animation starts when the hero scrolls into view, respects `prefers-reduced-motion` (rendering the finished transcript instantly), and exposes copy + replay controls. The animated transcript is `aria-hidden` while a screen-reader-only copy carries the real content.",
11231123
"scenarios": []
1124+
},
1125+
"hero-flow-field": {
1126+
"slug": "hero-flow-field",
1127+
"overview": "Scroll-driven canvas flow field. The streamline field's phase tracks `window.scrollY`, so the lines bend and wave as the page scrolls; a slow idle drift keeps it alive at rest. `FlowFieldBackground` is a separate export and can be reused under any composition.",
1128+
"scenarios": []
11241129
}
11251130
};

0 commit comments

Comments
 (0)