feat: implement the first version of landing page

This commit is contained in:
Henry Li
2026-01-23 13:24:03 +08:00
parent 307972f93e
commit 3f4bcd9433
25 changed files with 2576 additions and 241 deletions

View File

@@ -1,18 +1,24 @@
"use client"
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface FlickeringGridProps extends React.HTMLAttributes<HTMLDivElement> {
squareSize?: number
gridGap?: number
flickerChance?: number
color?: string
width?: number
height?: number
className?: string
maxOpacity?: number
squareSize?: number;
gridGap?: number;
flickerChance?: number;
color?: string;
width?: number;
height?: number;
className?: string;
maxOpacity?: number;
}
export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
@@ -26,58 +32,58 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
maxOpacity = 0.3,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const memoizedColor = useMemo(() => {
const toRGBA = (color: string) => {
if (typeof window === "undefined") {
return `rgba(0, 0, 0,`
return `rgba(0, 0, 0,`;
}
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const ctx = canvas.getContext("2d")
if (!ctx) return "rgba(255, 0, 0,"
ctx.fillStyle = color
ctx.fillRect(0, 0, 1, 1)
const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data)
return `rgba(${r}, ${g}, ${b},`
}
return toRGBA(color)
}, [color])
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d");
if (!ctx) return "rgba(255, 0, 0,";
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data);
return `rgba(${r}, ${g}, ${b},`;
};
return toRGBA(color);
}, [color]);
const setupCanvas = useCallback(
(canvas: HTMLCanvasElement, width: number, height: number) => {
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const cols = Math.floor(width / (squareSize + gridGap))
const rows = Math.floor(height / (squareSize + gridGap))
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const cols = Math.floor(width / (squareSize + gridGap));
const rows = Math.floor(height / (squareSize + gridGap));
const squares = new Float32Array(cols * rows)
const squares = new Float32Array(cols * rows);
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity
squares[i] = Math.random() * maxOpacity;
}
return { cols, rows, squares, dpr }
return { cols, rows, squares, dpr };
},
[squareSize, gridGap, maxOpacity]
)
[squareSize, gridGap, maxOpacity],
);
const updateSquares = useCallback(
(squares: Float32Array, deltaTime: number) => {
for (let i = 0; i < squares.length; i++) {
if (Math.random() < flickerChance * deltaTime) {
squares[i] = Math.random() * maxOpacity
squares[i] = Math.random() * maxOpacity;
}
}
},
[flickerChance, maxOpacity]
)
[flickerChance, maxOpacity],
);
const drawGrid = useCallback(
(
@@ -87,56 +93,56 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
cols: number,
rows: number,
squares: Float32Array,
dpr: number
dpr: number,
) => {
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = "transparent"
ctx.fillRect(0, 0, width, height)
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "transparent";
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const opacity = squares[i * rows + j]
ctx.fillStyle = `${memoizedColor}${opacity})`
const opacity = squares[i * rows + j];
ctx.fillStyle = `${memoizedColor}${opacity})`;
ctx.fillRect(
i * (squareSize + gridGap) * dpr,
j * (squareSize + gridGap) * dpr,
squareSize * dpr,
squareSize * dpr
)
squareSize * dpr,
);
}
}
},
[memoizedColor, squareSize, gridGap]
)
[memoizedColor, squareSize, gridGap],
);
useEffect(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d")
if (!ctx) return
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationFrameId: number
let gridParams: ReturnType<typeof setupCanvas>
let animationFrameId: number;
let gridParams: ReturnType<typeof setupCanvas>;
const updateCanvasSize = () => {
const newWidth = width || container.clientWidth
const newHeight = height || container.clientHeight
setCanvasSize({ width: newWidth, height: newHeight })
gridParams = setupCanvas(canvas, newWidth, newHeight)
}
const newWidth = width || container.clientWidth;
const newHeight = height || container.clientHeight;
setCanvasSize({ width: newWidth, height: newHeight });
gridParams = setupCanvas(canvas, newWidth, newHeight);
};
updateCanvasSize()
updateCanvasSize();
let lastTime = 0
let lastTime = 0;
const animate = (time: number) => {
if (!isInView) return
if (!isInView) return;
const deltaTime = (time - lastTime) / 1000
lastTime = time
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
updateSquares(gridParams.squares, deltaTime)
updateSquares(gridParams.squares, deltaTime);
drawGrid(
ctx,
canvas.width,
@@ -144,36 +150,38 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
gridParams.cols,
gridParams.rows,
gridParams.squares,
gridParams.dpr
)
animationFrameId = requestAnimationFrame(animate)
}
gridParams.dpr,
);
animationFrameId = requestAnimationFrame(animate);
};
const resizeObserver = new ResizeObserver(() => {
updateCanvasSize()
})
updateCanvasSize();
});
resizeObserver.observe(container)
resizeObserver.observe(container);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
setIsInView(entry.isIntersecting)
if (entry) {
setIsInView(entry.isIntersecting);
}
},
{ threshold: 0 }
)
{ threshold: 0 },
);
intersectionObserver.observe(canvas)
intersectionObserver.observe(canvas);
if (isInView) {
animationFrameId = requestAnimationFrame(animate)
animationFrameId = requestAnimationFrame(animate);
}
return () => {
cancelAnimationFrame(animationFrameId)
resizeObserver.disconnect()
intersectionObserver.disconnect()
}
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView])
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView]);
return (
<div
@@ -190,5 +198,5 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
}}
/>
</div>
)
}
);
};

View File

@@ -0,0 +1,5 @@
.galaxy-container {
width: 100%;
height: 100%;
position: relative;
}

View File

@@ -0,0 +1,344 @@
import { Renderer, Program, Mesh, Color, Triangle } from "ogl";
import { useEffect, useRef } from "react";
import "./galaxy.css";
const vertexShader = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0, 1);
}
`;
const fragmentShader = `
precision highp float;
uniform float uTime;
uniform vec3 uResolution;
uniform vec2 uFocal;
uniform vec2 uRotation;
uniform float uStarSpeed;
uniform float uDensity;
uniform float uHueShift;
uniform float uSpeed;
uniform vec2 uMouse;
uniform float uGlowIntensity;
uniform float uSaturation;
uniform bool uMouseRepulsion;
uniform float uTwinkleIntensity;
uniform float uRotationSpeed;
uniform float uRepulsionStrength;
uniform float uMouseActiveFactor;
uniform float uAutoCenterRepulsion;
uniform bool uTransparent;
varying vec2 vUv;
#define NUM_LAYER 4.0
#define STAR_COLOR_CUTOFF 0.2
#define MAT45 mat2(0.7071, -0.7071, 0.7071, 0.7071)
#define PERIOD 3.0
float Hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float tri(float x) {
return abs(fract(x) * 2.0 - 1.0);
}
float tris(float x) {
float t = fract(x);
return 1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0));
}
float trisn(float x) {
float t = fract(x);
return 2.0 * (1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0))) - 1.0;
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
float Star(vec2 uv, float flare) {
float d = length(uv);
float m = (0.05 * uGlowIntensity) / d;
float rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
m += rays * flare * uGlowIntensity;
uv *= MAT45;
rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
m += rays * 0.3 * flare * uGlowIntensity;
m *= smoothstep(1.0, 0.2, d);
return m;
}
vec3 StarLayer(vec2 uv) {
vec3 col = vec3(0.0);
vec2 gv = fract(uv) - 0.5;
vec2 id = floor(uv);
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 offset = vec2(float(x), float(y));
vec2 si = id + vec2(float(x), float(y));
float seed = Hash21(si);
float size = fract(seed * 345.32);
float glossLocal = tri(uStarSpeed / (PERIOD * seed + 1.0));
float flareSize = smoothstep(0.9, 1.0, size) * glossLocal;
float red = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 1.0)) + STAR_COLOR_CUTOFF;
float blu = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 3.0)) + STAR_COLOR_CUTOFF;
float grn = min(red, blu) * seed;
vec3 base = vec3(red, grn, blu);
float hue = atan(base.g - base.r, base.b - base.r) / (2.0 * 3.14159) + 0.5;
hue = fract(hue + uHueShift / 360.0);
float sat = length(base - vec3(dot(base, vec3(0.299, 0.587, 0.114)))) * uSaturation;
float val = max(max(base.r, base.g), base.b);
base = hsv2rgb(vec3(hue, sat, val));
vec2 pad = vec2(tris(seed * 34.0 + uTime * uSpeed / 10.0), tris(seed * 38.0 + uTime * uSpeed / 30.0)) - 0.5;
float star = Star(gv - offset - pad, flareSize);
vec3 color = base;
float twinkle = trisn(uTime * uSpeed + seed * 6.2831) * 0.5 + 1.0;
twinkle = mix(1.0, twinkle, uTwinkleIntensity);
star *= twinkle;
col += star * size * color;
}
}
return col;
}
void main() {
vec2 focalPx = uFocal * uResolution.xy;
vec2 uv = (vUv * uResolution.xy - focalPx) / uResolution.y;
vec2 mouseNorm = uMouse - vec2(0.5);
if (uAutoCenterRepulsion > 0.0) {
vec2 centerUV = vec2(0.0, 0.0);
float centerDist = length(uv - centerUV);
vec2 repulsion = normalize(uv - centerUV) * (uAutoCenterRepulsion / (centerDist + 0.1));
uv += repulsion * 0.05;
} else if (uMouseRepulsion) {
vec2 mousePosUV = (uMouse * uResolution.xy - focalPx) / uResolution.y;
float mouseDist = length(uv - mousePosUV);
vec2 repulsion = normalize(uv - mousePosUV) * (uRepulsionStrength / (mouseDist + 0.1));
uv += repulsion * 0.05 * uMouseActiveFactor;
} else {
vec2 mouseOffset = mouseNorm * 0.1 * uMouseActiveFactor;
uv += mouseOffset;
}
float autoRotAngle = uTime * uRotationSpeed;
mat2 autoRot = mat2(cos(autoRotAngle), -sin(autoRotAngle), sin(autoRotAngle), cos(autoRotAngle));
uv = autoRot * uv;
uv = mat2(uRotation.x, -uRotation.y, uRotation.y, uRotation.x) * uv;
vec3 col = vec3(0.0);
for (float i = 0.0; i < 1.0; i += 1.0 / NUM_LAYER) {
float depth = fract(i + uStarSpeed * uSpeed);
float scale = mix(20.0 * uDensity, 0.5 * uDensity, depth);
float fade = depth * smoothstep(1.0, 0.9, depth);
col += StarLayer(uv * scale + i * 453.32) * fade;
}
if (uTransparent) {
float alpha = length(col);
alpha = smoothstep(0.0, 0.3, alpha);
alpha = min(alpha, 1.0);
gl_FragColor = vec4(col, alpha);
} else {
gl_FragColor = vec4(col, 1.0);
}
}
`;
export default function Galaxy({
focal = [0.5, 0.5],
rotation = [1.0, 0.0],
starSpeed = 0.5,
density = 1,
hueShift = 140,
disableAnimation = false,
speed = 1.0,
mouseInteraction = true,
glowIntensity = 0.3,
saturation = 0.0,
mouseRepulsion = true,
repulsionStrength = 2,
twinkleIntensity = 0.3,
rotationSpeed = 0.1,
autoCenterRepulsion = 0,
transparent = true,
...rest
}) {
const ctnDom = useRef(null);
const targetMousePos = useRef({ x: 0.5, y: 0.5 });
const smoothMousePos = useRef({ x: 0.5, y: 0.5 });
const targetMouseActive = useRef(0.0);
const smoothMouseActive = useRef(0.0);
useEffect(() => {
if (!ctnDom.current) return;
const ctn = ctnDom.current;
const renderer = new Renderer({
alpha: transparent,
premultipliedAlpha: false,
});
const gl = renderer.gl;
if (transparent) {
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0, 0, 0, 0);
} else {
gl.clearColor(0, 0, 0, 1);
}
/** @type {Program | undefined} */
let program;
function resize() {
const scale = 1;
renderer.setSize(ctn.offsetWidth * scale, ctn.offsetHeight * scale);
if (program) {
program.uniforms.uResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height,
);
}
}
window.addEventListener("resize", resize, false);
resize();
const geometry = new Triangle(gl);
program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
uTime: { value: 0 },
uResolution: {
value: new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height,
),
},
uFocal: { value: new Float32Array(focal) },
uRotation: { value: new Float32Array(rotation) },
uStarSpeed: { value: starSpeed },
uDensity: { value: density },
uHueShift: { value: hueShift },
uSpeed: { value: speed },
uMouse: {
value: new Float32Array([
smoothMousePos.current.x,
smoothMousePos.current.y,
]),
},
uGlowIntensity: { value: glowIntensity },
uSaturation: { value: saturation },
uMouseRepulsion: { value: mouseRepulsion },
uTwinkleIntensity: { value: twinkleIntensity },
uRotationSpeed: { value: rotationSpeed },
uRepulsionStrength: { value: repulsionStrength },
uMouseActiveFactor: { value: 0.0 },
uAutoCenterRepulsion: { value: autoCenterRepulsion },
uTransparent: { value: transparent },
},
});
const mesh = new Mesh(gl, { geometry, program });
let animateId;
function update(t) {
animateId = requestAnimationFrame(update);
if (!disableAnimation) {
program.uniforms.uTime.value = t * 0.001;
program.uniforms.uStarSpeed.value = (t * 0.001 * starSpeed) / 10.0;
}
const lerpFactor = 0.05;
smoothMousePos.current.x +=
(targetMousePos.current.x - smoothMousePos.current.x) * lerpFactor;
smoothMousePos.current.y +=
(targetMousePos.current.y - smoothMousePos.current.y) * lerpFactor;
smoothMouseActive.current +=
(targetMouseActive.current - smoothMouseActive.current) * lerpFactor;
program.uniforms.uMouse.value[0] = smoothMousePos.current.x;
program.uniforms.uMouse.value[1] = smoothMousePos.current.y;
program.uniforms.uMouseActiveFactor.value = smoothMouseActive.current;
renderer.render({ scene: mesh });
}
animateId = requestAnimationFrame(update);
ctn.appendChild(gl.canvas);
function handleMouseMove(e) {
const rect = ctn.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1.0 - (e.clientY - rect.top) / rect.height;
targetMousePos.current = { x, y };
targetMouseActive.current = 1.0;
}
function handleMouseLeave() {
targetMouseActive.current = 0.0;
}
if (mouseInteraction) {
ctn.addEventListener("mousemove", handleMouseMove);
ctn.addEventListener("mouseleave", handleMouseLeave);
}
return () => {
cancelAnimationFrame(animateId);
window.removeEventListener("resize", resize);
if (mouseInteraction) {
ctn.removeEventListener("mousemove", handleMouseMove);
ctn.removeEventListener("mouseleave", handleMouseLeave);
}
ctn.removeChild(gl.canvas);
gl.getExtension("WEBGL_lose_context")?.loseContext();
};
}, [
focal,
rotation,
starSpeed,
density,
hueShift,
disableAnimation,
speed,
mouseInteraction,
glowIntensity,
saturation,
mouseRepulsion,
twinkleIntensity,
rotationSpeed,
repulsionStrength,
autoCenterRepulsion,
transparent,
]);
return <div ref={ctnDom} className="galaxy-container" {...rest} />;
}

View File

@@ -0,0 +1,217 @@
:root {
--hue: 27;
--sat: 69%;
--white: hsl(0, 0%, 100%);
--purple-primary: rgba(132, 0, 255, 1);
--purple-glow: rgba(132, 0, 255, 0.2);
--purple-border: rgba(132, 0, 255, 0.8);
--border-color: #392e4e;
--background-dark: #060010;
color-scheme: light dark;
}
.card-grid {
display: grid;
gap: 0.5em;
padding: 0.75em;
max-width: 54em;
font-size: clamp(1rem, 0.9rem + 0.5vw, 1.5rem);
}
.magic-bento-card {
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
aspect-ratio: 4/3;
min-height: 200px;
width: 100%;
max-width: 100%;
padding: 1.25em;
border-radius: 20px;
border: 1px solid var(--border-color);
background: var(--background-dark);
font-weight: 300;
overflow: hidden;
transition: all 0.3s ease;
--glow-x: 50%;
--glow-y: 50%;
--glow-intensity: 0;
--glow-radius: 200px;
}
.magic-bento-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.magic-bento-card__header,
.magic-bento-card__content {
display: flex;
position: relative;
color: var(--white);
}
.magic-bento-card__header {
gap: 0.75em;
justify-content: space-between;
}
.magic-bento-card__content {
flex-direction: column;
}
.magic-bento-card__label {
font-size: 16px;
}
.magic-bento-card__title,
.magic-bento-card__description {
--clamp-title: 1;
--clamp-desc: 2;
}
.magic-bento-card__title {
font-weight: 400;
font-size: 16px;
margin: 0 0 0.25em;
}
.magic-bento-card__description {
font-size: 12px;
line-height: 1.2;
opacity: 0.9;
}
.magic-bento-card--text-autohide .magic-bento-card__title,
.magic-bento-card--text-autohide .magic-bento-card__description {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.magic-bento-card--text-autohide .magic-bento-card__title {
-webkit-line-clamp: var(--clamp-title);
line-clamp: var(--clamp-title);
}
.magic-bento-card--text-autohide .magic-bento-card__description {
-webkit-line-clamp: var(--clamp-desc);
line-clamp: var(--clamp-desc);
}
@media (max-width: 599px) {
.card-grid {
grid-template-columns: 1fr;
width: 90%;
margin: 0 auto;
padding: 0.5em;
}
.magic-bento-card {
width: 100%;
min-height: 180px;
}
}
@media (min-width: 600px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.card-grid {
grid-template-columns: repeat(4, 1fr);
}
.magic-bento-card:nth-child(3) {
grid-column: span 2;
grid-row: span 2;
}
.magic-bento-card:nth-child(4) {
grid-column: 1 / span 2;
grid-row: 2 / span 2;
}
.magic-bento-card:nth-child(6) {
grid-column: 4;
grid-row: 3;
}
}
/* Border glow effect */
.magic-bento-card--border-glow::after {
content: '';
position: absolute;
inset: 0;
padding: 6px;
background: radial-gradient(
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
transparent 60%
);
border-radius: inherit;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
opacity: 1;
transition: opacity 0.3s ease;
z-index: 1;
}
.magic-bento-card--border-glow:hover::after {
opacity: 1;
}
.magic-bento-card--border-glow:hover {
box-shadow:
0 4px 20px rgba(46, 24, 78, 0.4),
0 0 30px var(--purple-glow);
}
.particle-container {
position: relative;
overflow: hidden;
}
.particle::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: rgba(132, 0, 255, 0.2);
border-radius: 50%;
z-index: -1;
}
.particle-container:hover {
box-shadow:
0 4px 20px rgba(46, 24, 78, 0.2),
0 0 30px var(--purple-glow);
}
/* Global spotlight styles */
.global-spotlight {
mix-blend-mode: screen;
will-change: transform, opacity;
z-index: 200 !important;
pointer-events: none;
}
.bento-section {
position: relative;
user-select: none;
}

View File

@@ -0,0 +1,757 @@
import { gsap } from "gsap";
import React, { useRef, useEffect, useCallback, useState } from "react";
import "./magic-bento.css";
export interface BentoCardProps {
color?: string;
title?: React.ReactNode;
description?: React.ReactNode;
label?: React.ReactNode;
textAutoHide?: boolean;
disableAnimations?: boolean;
}
export interface BentoProps {
textAutoHide?: boolean;
enableStars?: boolean;
enableSpotlight?: boolean;
enableBorderGlow?: boolean;
disableAnimations?: boolean;
spotlightRadius?: number;
particleCount?: number;
enableTilt?: boolean;
glowColor?: string;
clickEffect?: boolean;
enableMagnetism?: boolean;
}
const DEFAULT_PARTICLE_COUNT = 12;
const DEFAULT_SPOTLIGHT_RADIUS = 300;
const DEFAULT_GLOW_COLOR = "132, 0, 255";
const MOBILE_BREAKPOINT = 768;
const cardData: BentoCardProps[] = [
{
color: "#0a0015",
title: "Long/Short-term Memory",
description: (
<div>
<div>Now the agent can better understand you</div>
<div className="text-muted-foreground">Coming soon</div>
</div>
),
label: "Context Engineering",
},
{
color: "#0a0015",
title: "Planning and Reasoning",
description: "Plans ahead, reasons through complexity, then acts",
label: "Long Task Running",
},
{
color: "#0a0015",
title: "Skills and Tools",
description:
"Plug, play, or even swap built-in tools. Build the agent you want.",
label: "Extensible",
},
{
color: "#0a0015",
title: "Sandbox with File System",
description: "Read, write, run — like a real computer",
label: "Persistent",
},
{
color: "#0a0015",
title: "Multi-Model Support",
description: "Doubao, DeepSeek, OpenAI, Gemini, etc.",
label: "Flexible",
},
{
color: "#0a0015",
title: "Open Source",
description: "MIT License, self-hosted, full control",
label: "Free",
},
];
const createParticleElement = (
x: number,
y: number,
color: string = DEFAULT_GLOW_COLOR,
): HTMLDivElement => {
const el = document.createElement("div");
el.className = "particle";
el.style.cssText = `
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(${color}, 1);
box-shadow: 0 0 6px rgba(${color}, 0.6);
pointer-events: none;
z-index: 100;
left: ${x}px;
top: ${y}px;
`;
return el;
};
const calculateSpotlightValues = (radius: number) => ({
proximity: radius * 0.5,
fadeDistance: radius * 0.75,
});
const updateCardGlowProperties = (
card: HTMLElement,
mouseX: number,
mouseY: number,
glow: number,
radius: number,
) => {
const rect = card.getBoundingClientRect();
const relativeX = ((mouseX - rect.left) / rect.width) * 100;
const relativeY = ((mouseY - rect.top) / rect.height) * 100;
card.style.setProperty("--glow-x", `${relativeX}%`);
card.style.setProperty("--glow-y", `${relativeY}%`);
card.style.setProperty("--glow-intensity", glow.toString());
card.style.setProperty("--glow-radius", `${radius}px`);
};
const ParticleCard: React.FC<{
children: React.ReactNode;
className?: string;
disableAnimations?: boolean;
style?: React.CSSProperties;
particleCount?: number;
glowColor?: string;
enableTilt?: boolean;
clickEffect?: boolean;
enableMagnetism?: boolean;
}> = ({
children,
className = "",
disableAnimations = false,
style,
particleCount = DEFAULT_PARTICLE_COUNT,
glowColor = DEFAULT_GLOW_COLOR,
enableTilt = true,
clickEffect = false,
enableMagnetism = false,
}) => {
const cardRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<HTMLDivElement[]>([]);
const timeoutsRef = useRef<number[]>([]);
const isHoveredRef = useRef(false);
const memoizedParticles = useRef<HTMLDivElement[]>([]);
const particlesInitialized = useRef(false);
const magnetismAnimationRef = useRef<gsap.core.Tween | null>(null);
const initializeParticles = useCallback(() => {
if (particlesInitialized.current || !cardRef.current) return;
const { width, height } = cardRef.current.getBoundingClientRect();
memoizedParticles.current = Array.from({ length: particleCount }, () =>
createParticleElement(
Math.random() * width,
Math.random() * height,
glowColor,
),
);
particlesInitialized.current = true;
}, [particleCount, glowColor]);
const clearAllParticles = useCallback(() => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current = [];
magnetismAnimationRef.current?.kill();
particlesRef.current.forEach((particle) => {
gsap.to(particle, {
scale: 0,
opacity: 0,
duration: 0.3,
ease: "back.in(1.7)",
onComplete: () => {
particle.parentNode?.removeChild(particle);
},
});
});
particlesRef.current = [];
}, []);
const animateParticles = useCallback(() => {
if (!cardRef.current || !isHoveredRef.current) return;
if (!particlesInitialized.current) {
initializeParticles();
}
memoizedParticles.current.forEach((particle, index) => {
const timeoutId = setTimeout(() => {
if (!isHoveredRef.current || !cardRef.current) return;
const clone = particle.cloneNode(true) as HTMLDivElement;
cardRef.current.appendChild(clone);
particlesRef.current.push(clone);
gsap.fromTo(
clone,
{ scale: 0, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.3, ease: "back.out(1.7)" },
);
gsap.to(clone, {
x: (Math.random() - 0.5) * 100,
y: (Math.random() - 0.5) * 100,
rotation: Math.random() * 360,
duration: 2 + Math.random() * 2,
ease: "none",
repeat: -1,
yoyo: true,
});
gsap.to(clone, {
opacity: 0.3,
duration: 1.5,
ease: "power2.inOut",
repeat: -1,
yoyo: true,
});
}, index * 100);
timeoutsRef.current.push(timeoutId as unknown as number);
});
}, [initializeParticles]);
useEffect(() => {
if (disableAnimations || !cardRef.current) return;
const element = cardRef.current;
const handleMouseEnter = () => {
isHoveredRef.current = true;
animateParticles();
if (enableTilt) {
gsap.to(element, {
rotateX: 5,
rotateY: 5,
duration: 0.3,
ease: "power2.out",
transformPerspective: 1000,
});
}
};
const handleMouseLeave = () => {
isHoveredRef.current = false;
clearAllParticles();
if (enableTilt) {
gsap.to(element, {
rotateX: 0,
rotateY: 0,
duration: 0.3,
ease: "power2.out",
});
}
if (enableMagnetism) {
gsap.to(element, {
x: 0,
y: 0,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!enableTilt && !enableMagnetism) return;
const rect = element.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
if (enableTilt) {
const rotateX = ((y - centerY) / centerY) * -10;
const rotateY = ((x - centerX) / centerX) * 10;
gsap.to(element, {
rotateX,
rotateY,
duration: 0.1,
ease: "power2.out",
transformPerspective: 1000,
});
}
if (enableMagnetism) {
const magnetX = (x - centerX) * 0.05;
const magnetY = (y - centerY) * 0.05;
magnetismAnimationRef.current = gsap.to(element, {
x: magnetX,
y: magnetY,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleClick = (e: MouseEvent) => {
if (!clickEffect) return;
const rect = element.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const maxDistance = Math.max(
Math.hypot(x, y),
Math.hypot(x - rect.width, y),
Math.hypot(x, y - rect.height),
Math.hypot(x - rect.width, y - rect.height),
);
const ripple = document.createElement("div");
ripple.style.cssText = `
position: absolute;
width: ${maxDistance * 2}px;
height: ${maxDistance * 2}px;
border-radius: 50%;
background: radial-gradient(circle, rgba(${glowColor}, 0.4) 0%, rgba(${glowColor}, 0.2) 30%, transparent 70%);
left: ${x - maxDistance}px;
top: ${y - maxDistance}px;
pointer-events: none;
z-index: 1000;
`;
element.appendChild(ripple);
gsap.fromTo(
ripple,
{
scale: 0,
opacity: 1,
},
{
scale: 1,
opacity: 0,
duration: 0.8,
ease: "power2.out",
onComplete: () => ripple.remove(),
},
);
};
element.addEventListener("mouseenter", handleMouseEnter);
element.addEventListener("mouseleave", handleMouseLeave);
element.addEventListener("mousemove", handleMouseMove);
element.addEventListener("click", handleClick);
return () => {
isHoveredRef.current = false;
element.removeEventListener("mouseenter", handleMouseEnter);
element.removeEventListener("mouseleave", handleMouseLeave);
element.removeEventListener("mousemove", handleMouseMove);
element.removeEventListener("click", handleClick);
clearAllParticles();
};
}, [
animateParticles,
clearAllParticles,
disableAnimations,
enableTilt,
enableMagnetism,
clickEffect,
glowColor,
]);
return (
<div
ref={cardRef}
className={`${className} particle-container`}
style={{ ...style, position: "relative", overflow: "hidden" }}
>
{children}
</div>
);
};
const GlobalSpotlight: React.FC<{
gridRef: React.RefObject<HTMLDivElement | null>;
disableAnimations?: boolean;
enabled?: boolean;
spotlightRadius?: number;
glowColor?: string;
}> = ({
gridRef,
disableAnimations = false,
enabled = true,
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
glowColor = DEFAULT_GLOW_COLOR,
}) => {
const spotlightRef = useRef<HTMLDivElement | null>(null);
const isInsideSection = useRef(false);
useEffect(() => {
if (disableAnimations || !gridRef?.current || !enabled) return;
const spotlight = document.createElement("div");
spotlight.className = "global-spotlight";
spotlight.style.cssText = `
position: fixed;
width: 800px;
height: 800px;
border-radius: 50%;
pointer-events: none;
background: radial-gradient(circle,
rgba(${glowColor}, 0.15) 0%,
rgba(${glowColor}, 0.08) 15%,
rgba(${glowColor}, 0.04) 25%,
rgba(${glowColor}, 0.02) 40%,
rgba(${glowColor}, 0.01) 65%,
transparent 70%
);
z-index: 200;
opacity: 0;
transform: translate(-50%, -50%);
mix-blend-mode: screen;
`;
document.body.appendChild(spotlight);
spotlightRef.current = spotlight;
const handleMouseMove = (e: MouseEvent) => {
if (!spotlightRef.current || !gridRef.current) return;
const section = gridRef.current.closest(".bento-section");
const rect = section?.getBoundingClientRect();
const mouseInside =
rect &&
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
isInsideSection.current = mouseInside ?? false;
const cards = gridRef.current.querySelectorAll(".magic-bento-card");
if (!mouseInside) {
gsap.to(spotlightRef.current, {
opacity: 0,
duration: 0.3,
ease: "power2.out",
});
cards.forEach((card) => {
(card as HTMLElement).style.setProperty("--glow-intensity", "0");
});
return;
}
const { proximity, fadeDistance } =
calculateSpotlightValues(spotlightRadius);
let minDistance = Infinity;
cards.forEach((card) => {
const cardElement = card as HTMLElement;
const cardRect = cardElement.getBoundingClientRect();
const centerX = cardRect.left + cardRect.width / 2;
const centerY = cardRect.top + cardRect.height / 2;
const distance =
Math.hypot(e.clientX - centerX, e.clientY - centerY) -
Math.max(cardRect.width, cardRect.height) / 2;
const effectiveDistance = Math.max(0, distance);
minDistance = Math.min(minDistance, effectiveDistance);
let glowIntensity = 0;
if (effectiveDistance <= proximity) {
glowIntensity = 1;
} else if (effectiveDistance <= fadeDistance) {
glowIntensity =
(fadeDistance - effectiveDistance) / (fadeDistance - proximity);
}
updateCardGlowProperties(
cardElement,
e.clientX,
e.clientY,
glowIntensity,
spotlightRadius,
);
});
gsap.to(spotlightRef.current, {
left: e.clientX,
top: e.clientY,
duration: 0.1,
ease: "power2.out",
});
const targetOpacity =
minDistance <= proximity
? 0.8
: minDistance <= fadeDistance
? ((fadeDistance - minDistance) / (fadeDistance - proximity)) * 0.8
: 0;
gsap.to(spotlightRef.current, {
opacity: targetOpacity,
duration: targetOpacity > 0 ? 0.2 : 0.5,
ease: "power2.out",
});
};
const handleMouseLeave = () => {
isInsideSection.current = false;
gridRef.current?.querySelectorAll(".magic-bento-card").forEach((card) => {
(card as HTMLElement).style.setProperty("--glow-intensity", "0");
});
if (spotlightRef.current) {
gsap.to(spotlightRef.current, {
opacity: 0,
duration: 0.3,
ease: "power2.out",
});
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseleave", handleMouseLeave);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseleave", handleMouseLeave);
spotlightRef.current?.parentNode?.removeChild(spotlightRef.current);
};
}, [gridRef, disableAnimations, enabled, spotlightRadius, glowColor]);
return null;
};
const BentoCardGrid: React.FC<{
children: React.ReactNode;
gridRef?: React.RefObject<HTMLDivElement | null>;
}> = ({ children, gridRef }) => (
<div className="card-grid bento-section" ref={gridRef}>
{children}
</div>
);
const useMobileDetection = () => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () =>
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return isMobile;
};
const MagicBento: React.FC<BentoProps> = ({
textAutoHide = true,
enableStars = true,
enableSpotlight = true,
enableBorderGlow = true,
disableAnimations = false,
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
particleCount = DEFAULT_PARTICLE_COUNT,
enableTilt = false,
glowColor = DEFAULT_GLOW_COLOR,
clickEffect = true,
enableMagnetism = true,
}) => {
const gridRef = useRef<HTMLDivElement>(null);
const isMobile = useMobileDetection();
const shouldDisableAnimations = disableAnimations || isMobile;
return (
<>
{enableSpotlight && (
<GlobalSpotlight
gridRef={gridRef}
disableAnimations={shouldDisableAnimations}
enabled={enableSpotlight}
spotlightRadius={spotlightRadius}
glowColor={glowColor}
/>
)}
<BentoCardGrid gridRef={gridRef}>
{cardData.map((card, index) => {
const baseClassName = `magic-bento-card ${textAutoHide ? "magic-bento-card--text-autohide" : ""} ${enableBorderGlow ? "magic-bento-card--border-glow" : ""}`;
const cardProps = {
className: baseClassName,
style: {
backgroundColor: card.color,
"--glow-color": glowColor,
} as React.CSSProperties,
};
if (enableStars) {
return (
<ParticleCard
key={index}
{...cardProps}
disableAnimations={shouldDisableAnimations}
particleCount={particleCount}
glowColor={glowColor}
enableTilt={enableTilt}
clickEffect={clickEffect}
enableMagnetism={enableMagnetism}
>
<div className="magic-bento-card__header">
<div className="magic-bento-card__label">{card.label}</div>
</div>
<div className="magic-bento-card__content">
<h2 className="magic-bento-card__title">{card.title}</h2>
<div className="magic-bento-card__description">
{card.description}
</div>
</div>
</ParticleCard>
);
}
return (
<div
key={index}
{...cardProps}
ref={(el) => {
if (!el) return;
const handleMouseMove = (e: MouseEvent) => {
if (shouldDisableAnimations) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
if (enableTilt) {
const rotateX = ((y - centerY) / centerY) * -10;
const rotateY = ((x - centerX) / centerX) * 10;
gsap.to(el, {
rotateX,
rotateY,
duration: 0.1,
ease: "power2.out",
transformPerspective: 1000,
});
}
if (enableMagnetism) {
const magnetX = (x - centerX) * 0.05;
const magnetY = (y - centerY) * 0.05;
gsap.to(el, {
x: magnetX,
y: magnetY,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleMouseLeave = () => {
if (shouldDisableAnimations) return;
if (enableTilt) {
gsap.to(el, {
rotateX: 0,
rotateY: 0,
duration: 0.3,
ease: "power2.out",
});
}
if (enableMagnetism) {
gsap.to(el, {
x: 0,
y: 0,
duration: 0.3,
ease: "power2.out",
});
}
};
const handleClick = (e: MouseEvent) => {
if (!clickEffect || shouldDisableAnimations) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Calculate the maximum distance from click point to any corner
const maxDistance = Math.max(
Math.hypot(x, y),
Math.hypot(x - rect.width, y),
Math.hypot(x, y - rect.height),
Math.hypot(x - rect.width, y - rect.height),
);
const ripple = document.createElement("div");
ripple.style.cssText = `
position: absolute;
width: ${maxDistance * 2}px;
height: ${maxDistance * 2}px;
border-radius: 50%;
background: radial-gradient(circle, rgba(${glowColor}, 0.4) 0%, rgba(${glowColor}, 0.2) 30%, transparent 70%);
left: ${x - maxDistance}px;
top: ${y - maxDistance}px;
pointer-events: none;
z-index: 1000;
`;
el.appendChild(ripple);
gsap.fromTo(
ripple,
{
scale: 0,
opacity: 1,
},
{
scale: 1,
opacity: 0,
duration: 0.8,
ease: "power2.out",
onComplete: () => ripple.remove(),
},
);
};
el.addEventListener("mousemove", handleMouseMove);
el.addEventListener("mouseleave", handleMouseLeave);
el.addEventListener("click", handleClick);
}}
>
<div className="magic-bento-card__header">
<div className="magic-bento-card__label">{card.label}</div>
</div>
<div className="magic-bento-card__content">
<h2 className="magic-bento-card__title">{card.title}</h2>
<p className="magic-bento-card__description">
{card.description}
</p>
</div>
</div>
);
})}
</BentoCardGrid>
</>
);
};
export default MagicBento;

View File

@@ -1,16 +1,16 @@
"use client"
"use client";
import { ComponentPropsWithoutRef, useEffect, useRef } from "react"
import { useInView, useMotionValue, useSpring } from "motion/react"
import { type ComponentPropsWithoutRef, useEffect, useRef } from "react";
import { useInView, useMotionValue, useSpring } from "motion/react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
value: number
startValue?: number
direction?: "up" | "down"
delay?: number
decimalPlaces?: number
value: number;
startValue?: number;
direction?: "up" | "down";
delay?: number;
decimalPlaces?: number;
}
export function NumberTicker({
@@ -22,22 +22,22 @@ export function NumberTicker({
decimalPlaces = 0,
...props
}: NumberTickerProps) {
const ref = useRef<HTMLSpanElement>(null)
const motionValue = useMotionValue(direction === "down" ? value : startValue)
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? value : startValue);
const springValue = useSpring(motionValue, {
damping: 60,
stiffness: 100,
})
const isInView = useInView(ref, { once: true, margin: "0px" })
});
const isInView = useInView(ref, { once: true, margin: "0px" });
useEffect(() => {
if (isInView) {
const timer = setTimeout(() => {
motionValue.set(direction === "down" ? startValue : value)
}, delay * 1000)
return () => clearTimeout(timer)
motionValue.set(direction === "down" ? startValue : value);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [motionValue, isInView, delay, value, direction, startValue])
}, [motionValue, isInView, delay, value, direction, startValue]);
useEffect(
() =>
@@ -46,22 +46,22 @@ export function NumberTicker({
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)))
}).format(Number(latest.toFixed(decimalPlaces)));
}
}),
[springValue, decimalPlaces]
)
[springValue, decimalPlaces],
);
return (
<span
ref={ref}
className={cn(
"inline-block tracking-wider text-black tabular-nums dark:text-white",
className
className,
)}
{...props}
>
{startValue}
</span>
)
);
}

View File

@@ -0,0 +1,29 @@
.card-spotlight {
position: relative;
border-radius: 1.5rem;
border: 1px solid #222;
background-color: #111;
padding: 2rem;
overflow: hidden;
--mouse-x: 50%;
--mouse-y: 50%;
--spotlight-color: rgba(255, 255, 255, 0.05);
}
.card-spotlight::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at var(--mouse-x) var(--mouse-y), var(--spotlight-color), transparent 80%);
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
.card-spotlight:hover::before,
.card-spotlight:focus-within::before {
opacity: 0.6;
}

View File

@@ -0,0 +1,46 @@
"use client";
import React, { useRef } from "react";
import "./spotlight-card.css";
interface Position {
x: number;
y: number;
}
interface SpotlightCardProps extends React.PropsWithChildren {
className?: string;
spotlightColor?: `rgba(${number}, ${number}, ${number}, ${number})`;
}
const SpotlightCard: React.FC<SpotlightCardProps> = ({
children,
className = "",
spotlightColor = "rgba(255, 255, 255, 0.25)",
}) => {
const divRef = useRef<HTMLDivElement>(null);
const handleMouseMove: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!divRef.current) return;
const rect = divRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
divRef.current.style.setProperty("--mouse-x", `${x}px`);
divRef.current.style.setProperty("--mouse-y", `${y}px`);
divRef.current.style.setProperty("--spotlight-color", spotlightColor);
};
return (
<div
ref={divRef}
onMouseMove={handleMouseMove}
className={`card-spotlight ${className}`}
>
{children}
</div>
);
};
export default SpotlightCard;

View File

@@ -0,0 +1,257 @@
"use client";
import {
Children,
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { motion, type MotionProps, useInView } from "motion/react";
import { cn } from "@/lib/utils";
interface SequenceContextValue {
completeItem: (index: number) => void;
activeIndex: number;
sequenceStarted: boolean;
}
const SequenceContext = createContext<SequenceContextValue | null>(null);
const useSequence = () => useContext(SequenceContext);
const ItemIndexContext = createContext<number | null>(null);
const useItemIndex = () => useContext(ItemIndexContext);
interface AnimatedSpanProps extends MotionProps {
children: React.ReactNode;
delay?: number;
className?: string;
startOnView?: boolean;
}
export const AnimatedSpan = ({
children,
delay = 0,
className,
startOnView = false,
...props
}: AnimatedSpanProps) => {
const elementRef = useRef<HTMLDivElement | null>(null);
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
});
const sequence = useSequence();
const itemIndex = useItemIndex();
const [hasStarted, setHasStarted] = useState(false);
useEffect(() => {
if (!sequence || itemIndex === null) return;
if (!sequence.sequenceStarted) return;
if (hasStarted) return;
if (sequence.activeIndex === itemIndex) {
setHasStarted(true);
}
}, [sequence?.activeIndex, sequence?.sequenceStarted, hasStarted, itemIndex]);
const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true;
return (
<motion.div
ref={elementRef}
initial={{ opacity: 0, y: -5 }}
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
transition={{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }}
className={cn("grid text-sm font-normal tracking-tight", className)}
onAnimationComplete={() => {
if (!sequence) return;
if (itemIndex === null) return;
sequence.completeItem(itemIndex);
}}
{...props}
>
{children}
</motion.div>
);
};
interface TypingAnimationProps extends MotionProps {
children: string;
className?: string;
duration?: number;
delay?: number;
as?: React.ElementType;
startOnView?: boolean;
}
export const TypingAnimation = ({
children,
className,
duration = 60,
delay = 0,
as: Component = "span",
startOnView = true,
...props
}: TypingAnimationProps) => {
if (typeof children !== "string") {
throw new Error("TypingAnimation: children must be a string. Received:");
}
const MotionComponent = useMemo(
() =>
motion.create(Component, {
forwardMotionProps: true,
}),
[Component],
);
const [displayedText, setDisplayedText] = useState<string>("");
const [started, setStarted] = useState(false);
const elementRef = useRef<HTMLElement | null>(null);
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
});
const sequence = useSequence();
const itemIndex = useItemIndex();
useEffect(() => {
if (sequence && itemIndex !== null) {
if (!sequence.sequenceStarted) return;
if (started) return;
if (sequence.activeIndex === itemIndex) {
setStarted(true);
}
return;
}
if (!startOnView) {
const startTimeout = setTimeout(() => setStarted(true), delay);
return () => clearTimeout(startTimeout);
}
if (!isInView) return;
const startTimeout = setTimeout(() => setStarted(true), delay);
return () => clearTimeout(startTimeout);
}, [
delay,
startOnView,
isInView,
started,
sequence?.activeIndex,
sequence?.sequenceStarted,
itemIndex,
]);
useEffect(() => {
if (!started) return;
let i = 0;
const typingEffect = setInterval(() => {
if (i < children.length) {
setDisplayedText(children.substring(0, i + 1));
i++;
} else {
clearInterval(typingEffect);
if (sequence && itemIndex !== null) {
sequence.completeItem(itemIndex);
}
}
}, duration);
return () => {
clearInterval(typingEffect);
};
}, [children, duration, started]);
return (
<MotionComponent
ref={elementRef}
className={cn("text-sm font-normal tracking-tight", className)}
{...props}
>
{displayedText}
</MotionComponent>
);
};
interface TerminalProps {
children: React.ReactNode;
className?: string;
sequence?: boolean;
startOnView?: boolean;
}
export const Terminal = ({
children,
className,
sequence = true,
startOnView = true,
}: TerminalProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const isInView = useInView(containerRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
});
const [activeIndex, setActiveIndex] = useState(0);
const sequenceHasStarted = sequence ? !startOnView || isInView : false;
const contextValue = useMemo<SequenceContextValue | null>(() => {
if (!sequence) return null;
return {
completeItem: (index: number) => {
setActiveIndex((current) =>
index === current ? current + 1 : current,
);
},
activeIndex,
sequenceStarted: sequenceHasStarted,
};
}, [sequence, activeIndex, sequenceHasStarted]);
const wrappedChildren = useMemo(() => {
if (!sequence) return children;
const array = Children.toArray(children);
return array.map((child, index) => (
<ItemIndexContext.Provider key={index} value={index}>
{child as React.ReactNode}
</ItemIndexContext.Provider>
));
}, [children, sequence]);
const content = (
<div
ref={containerRef}
className={cn(
"border-border bg-background z-0 h-full max-h-[400px] w-full max-w-lg rounded-xl border",
className,
)}
>
<div className="border-border flex flex-col gap-y-2 border-b p-4">
<div className="flex flex-row gap-x-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
<div className="h-2 w-2 rounded-full bg-green-500"></div>
</div>
</div>
<pre className="p-4">
<code className="grid gap-y-1 overflow-auto">{wrappedChildren}</code>
</pre>
</div>
);
if (!sequence) return content;
return (
<SequenceContext.Provider value={contextValue}>
{content}
</SequenceContext.Provider>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, MotionProps } from "motion/react";
import { AnimatePresence, motion, type MotionProps } from "motion/react";
import { cn } from "@/lib/utils";
import { AuroraText } from "./aurora-text";
@@ -15,12 +15,12 @@ interface WordRotateProps {
export function WordRotate({
words,
duration = 2500,
duration = 2200,
motionProps = {
initial: { opacity: 0, y: -50, filter: "blur(16px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: 50, filter: "blur(16px)" },
transition: { duration: 0.25, ease: "easeOut" },
transition: { duration: 0.3, ease: "easeOut" },
},
className,
}: WordRotateProps) {
@@ -37,7 +37,7 @@ export function WordRotate({
return (
<div className="overflow-hidden py-2">
<AnimatePresence mode="wait">
<AnimatePresence mode="popLayout">
<motion.h1
key={words[index]}
className={cn(className)}