feat: add particle motion to agent showcase banner
This commit is contained in:
parent
fe8a8564e7
commit
f83230d7e9
208
components/agents/AgentWorkflowParticleScatter.tsx
Normal file
208
components/agents/AgentWorkflowParticleScatter.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentWorkflowParticleScatterProps = {
|
||||
className?: string;
|
||||
density?: "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
speed: number;
|
||||
drift: number;
|
||||
alpha: number;
|
||||
hue: number;
|
||||
phase: number;
|
||||
swing: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const PARTICLE_DENSITY_MULTIPLIER = {
|
||||
low: 0.75,
|
||||
medium: 1,
|
||||
high: 1.35,
|
||||
} as const;
|
||||
|
||||
function randomBetween(min: number, max: number) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
export function AgentWorkflowParticleScatter({
|
||||
className,
|
||||
density = "medium",
|
||||
}: AgentWorkflowParticleScatterProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let animationFrameId = 0;
|
||||
let particles: Particle[] = [];
|
||||
|
||||
const buildParticle = (): Particle => {
|
||||
const depth = randomBetween(0.65, 1.15);
|
||||
const spread = width * randomBetween(0.16, 0.48);
|
||||
const originX = width / 2 + randomBetween(-spread, spread);
|
||||
const originY = height * randomBetween(0.56, 1.06);
|
||||
|
||||
return {
|
||||
x: originX,
|
||||
y: originY,
|
||||
radius: randomBetween(0.9, 2.8) * depth,
|
||||
speed: randomBetween(0.08, 0.34) * depth,
|
||||
drift: randomBetween(0.12, 0.38) * (Math.random() > 0.5 ? 1 : -1),
|
||||
alpha: randomBetween(0.16, 0.84),
|
||||
hue: Math.random() > 0.7 ? 0 : 160,
|
||||
phase: randomBetween(0, Math.PI * 2),
|
||||
swing: randomBetween(10, 42),
|
||||
depth,
|
||||
};
|
||||
};
|
||||
|
||||
const resetParticle = (particle: Particle, respawnNearBottom = false) => {
|
||||
const next = buildParticle();
|
||||
particle.x = next.x;
|
||||
particle.y = respawnNearBottom ? height * randomBetween(0.72, 1.04) : next.y;
|
||||
particle.radius = next.radius;
|
||||
particle.speed = next.speed;
|
||||
particle.drift = next.drift;
|
||||
particle.alpha = next.alpha;
|
||||
particle.hue = next.hue;
|
||||
particle.phase = next.phase;
|
||||
particle.swing = next.swing;
|
||||
particle.depth = next.depth;
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = Math.max(1, bounds.width);
|
||||
height = Math.max(1, bounds.height);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
|
||||
const baseCount = Math.round((width / 12) * PARTICLE_DENSITY_MULTIPLIER[density]);
|
||||
particles = Array.from(
|
||||
{ length: Math.max(56, Math.min(220, baseCount)) },
|
||||
() => buildParticle()
|
||||
);
|
||||
};
|
||||
|
||||
const drawGlow = (x: number, y: number, radius: number, alpha: number, hue: number) => {
|
||||
const gradient = context.createRadialGradient(x, y, 0, x, y, radius * 5.5);
|
||||
if (hue === 160) {
|
||||
gradient.addColorStop(0, `rgba(110, 255, 217, ${alpha})`);
|
||||
gradient.addColorStop(0.35, `rgba(26, 214, 168, ${alpha * 0.7})`);
|
||||
gradient.addColorStop(1, "rgba(26, 214, 168, 0)");
|
||||
} else {
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${alpha})`);
|
||||
gradient.addColorStop(0.42, `rgba(209, 255, 244, ${alpha * 0.45})`);
|
||||
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
|
||||
}
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
context.arc(x, y, radius * 5.5, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
};
|
||||
|
||||
const render = (time: number) => {
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const bottomGlow = context.createRadialGradient(
|
||||
width / 2,
|
||||
height * 0.92,
|
||||
0,
|
||||
width / 2,
|
||||
height * 0.92,
|
||||
width * 0.42
|
||||
);
|
||||
bottomGlow.addColorStop(0, "rgba(19, 221, 179, 0.18)");
|
||||
bottomGlow.addColorStop(0.5, "rgba(19, 221, 179, 0.08)");
|
||||
bottomGlow.addColorStop(1, "rgba(19, 221, 179, 0)");
|
||||
context.fillStyle = bottomGlow;
|
||||
context.fillRect(0, 0, width, height);
|
||||
|
||||
particles.forEach((particle) => {
|
||||
particle.phase += 0.012;
|
||||
particle.y -= particle.speed;
|
||||
particle.x += Math.sin(time * 0.00032 + particle.phase) * particle.drift;
|
||||
|
||||
const waveOffset =
|
||||
Math.sin(time * 0.001 + particle.phase * 2.4) * particle.swing * 0.018;
|
||||
const pulseAlpha =
|
||||
particle.alpha * (0.7 + 0.3 * Math.sin(time * 0.0016 + particle.phase));
|
||||
const renderX = particle.x + waveOffset;
|
||||
const renderY = particle.y;
|
||||
|
||||
if (renderY < -24 || renderX < -32 || renderX > width + 32) {
|
||||
resetParticle(particle, true);
|
||||
}
|
||||
|
||||
drawGlow(renderX, renderY, particle.radius, pulseAlpha * 0.68, particle.hue);
|
||||
|
||||
context.beginPath();
|
||||
context.fillStyle =
|
||||
particle.hue === 160
|
||||
? `rgba(155, 255, 230, ${pulseAlpha})`
|
||||
: `rgba(255, 255, 255, ${pulseAlpha})`;
|
||||
context.arc(renderX, renderY, particle.radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
|
||||
if (!reducedMotionQuery.matches) {
|
||||
animationFrameId = window.requestAnimationFrame(render);
|
||||
}
|
||||
};
|
||||
|
||||
resize();
|
||||
|
||||
const handleResize = () => {
|
||||
resize();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
if (reducedMotionQuery.matches) {
|
||||
render(0);
|
||||
} else {
|
||||
animationFrameId = window.requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (animationFrameId) {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}, [density]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-full w-full [mask-image:linear-gradient(to_top,black_58%,transparent_100%)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, ImageIcon } from "lucide-react";
|
||||
import { AgentWorkflowParticleScatter } from "@/components/agents/AgentWorkflowParticleScatter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defaultAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
|
||||
@ -33,44 +34,55 @@ export function AgentWorkflowShowcaseBanner({
|
||||
return (
|
||||
<div className={cn("space-y-12 pt-6 md:space-y-14 md:pt-8", className)}>
|
||||
<motion.div
|
||||
className="text-center"
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-[34px] border border-white/10 px-6 py-14 text-center",
|
||||
"bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.05),transparent_46%),linear-gradient(180deg,rgba(255,255,255,0.02),rgba(5,8,7,0.9))]",
|
||||
"shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_48px_rgba(0,0,0,0.24)]",
|
||||
"sm:px-10 sm:py-16"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">{resolved.title}</h1>
|
||||
<p className="mb-8 text-base text-white/50 md:mb-9">{resolved.subtitle}</p>
|
||||
{resolvedActionLabel || secondaryActionLabel ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
{resolvedActionLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onActionClick}
|
||||
className={cn(
|
||||
"h-12 gap-2 rounded-full px-8",
|
||||
"bg-gradient-to-r from-emerald-500 to-teal-500",
|
||||
"text-base font-medium text-white",
|
||||
"hover:from-emerald-400 hover:to-teal-400",
|
||||
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
|
||||
"transition-all duration-300"
|
||||
)}
|
||||
>
|
||||
{resolvedActionLabel}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{secondaryActionLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSecondaryActionClick}
|
||||
className="h-12 rounded-full border-white/20 bg-transparent px-12 text-base font-medium text-white hover:border-emerald-400/50 hover:bg-white/[0.04]"
|
||||
>
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<AgentWorkflowParticleScatter className="h-[48%]" density="high" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[44%] bg-[radial-gradient(circle_at_50%_100%,rgba(16,185,129,0.22),transparent_52%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[32%] bg-gradient-to-t from-black via-black/35 to-transparent" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">{resolved.title}</h1>
|
||||
<p className="mb-8 text-base text-white/50 md:mb-9">{resolved.subtitle}</p>
|
||||
{resolvedActionLabel || secondaryActionLabel ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
{resolvedActionLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onActionClick}
|
||||
className={cn(
|
||||
"h-12 gap-2 rounded-full px-8",
|
||||
"bg-gradient-to-r from-emerald-500 to-teal-500",
|
||||
"text-base font-medium text-white",
|
||||
"hover:from-emerald-400 hover:to-teal-400",
|
||||
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
|
||||
"transition-all duration-300"
|
||||
)}
|
||||
>
|
||||
{resolvedActionLabel}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{secondaryActionLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSecondaryActionClick}
|
||||
className="h-12 rounded-full border-white/20 bg-transparent px-12 text-base font-medium text-white hover:border-emerald-400/50 hover:bg-white/[0.04]"
|
||||
>
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{shouldRenderMedia ? (
|
||||
@ -104,6 +116,7 @@ export function AgentWorkflowShowcaseBanner({
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.12),transparent_42%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/35" />
|
||||
<AgentWorkflowParticleScatter className="h-[42%] opacity-60" density="medium" />
|
||||
|
||||
{resolved.overlayText ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-8 text-center">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user