Add geometric element

This commit is contained in:
2026-03-05 10:10:59 +00:00
parent 3eef7d36dd
commit 37702f5fd3
12 changed files with 586 additions and 9 deletions
+4
View File
@@ -18,6 +18,10 @@
--font-cursive: var(--font-cursive);
}
html {
overflow-y: overlay;
}
body {
background: var(--background);
color: var(--foreground);
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Home() {
<main>
<Landing />
<section id="about" className="min-h-[150vh] px-8 py-16 md:px-16 lg:px-24">
<section id="about" className="px-8 py-16 md:px-16 lg:px-24">
<div className="mx-auto max-w-4xl space-y-16">
<div className="space-y-8">
<h2 className="font-bold font-heading text-5xl md:text-7xl">About</h2>
@@ -0,0 +1,260 @@
import {
BLUE,
CYAN,
DRIFT_AMOUNT,
EDGE_APPEAR_SPEED,
EDGE_DISAPPEAR_SPEED,
NEIGHBORS,
PROXIMITY_THRESHOLD,
REPEL_STRENGTH,
SPRING_DAMPING,
SPRING_MASS,
SPRING_STIFFNESS,
VIOLET,
} from "./constants";
import { type Color, EdgePhase, type EdgeState, type Point } from "./types";
type Cursor = { x: number; y: number };
export const updatePoints = (
points: Point[],
elapsed: number,
deltaTime: number,
cursor: Cursor,
rect: DOMRect,
scaleX: number,
scaleY: number,
isTouchDevice: boolean,
) => {
for (const point of points) {
const driftX = Math.sin(elapsed * point.driftSpeedX + point.driftPhaseX) * DRIFT_AMOUNT;
const driftY = Math.sin(elapsed * point.driftSpeedY + point.driftPhaseY) * DRIFT_AMOUNT;
point.targetOffsetX = 0;
point.targetOffsetY = 0;
if (!isTouchDevice && cursor.x !== 0) {
const pointScreenX = rect.left + (point.baseX + driftX) * scaleX;
const pointScreenY = rect.top + (point.baseY + driftY) * scaleY;
const dx = cursor.x - pointScreenX;
const dy = cursor.y - pointScreenY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < PROXIMITY_THRESHOLD && distance > 0) {
const influence = (1 - distance / PROXIMITY_THRESHOLD) ** 2;
point.targetOffsetX = -(dx / distance) * influence * REPEL_STRENGTH;
point.targetOffsetY = -(dy / distance) * influence * REPEL_STRENGTH;
}
}
const forceX = (point.targetOffsetX - point.currentOffsetX) * SPRING_STIFFNESS;
const forceY = (point.targetOffsetY - point.currentOffsetY) * SPRING_STIFFNESS;
const dampingX = point.velocityX * SPRING_DAMPING;
const dampingY = point.velocityY * SPRING_DAMPING;
point.velocityX += ((forceX - dampingX) / SPRING_MASS) * deltaTime;
point.velocityY += ((forceY - dampingY) / SPRING_MASS) * deltaTime;
point.currentOffsetX += point.velocityX * deltaTime;
point.currentOffsetY += point.velocityY * deltaTime;
point.x = point.baseX + driftX + point.currentOffsetX;
point.y = point.baseY + driftY + point.currentOffsetY;
}
};
export const findCurrentEdges = (points: Point[]): Set<string> => {
const currentEdges = new Set<string>();
for (const point of points) {
const distances = points
.filter((p) => p.id !== point.id)
.map((other) => ({
other,
dist: Math.sqrt((point.x - other.x) ** 2 + (point.y - other.y) ** 2),
}))
.sort((a, b) => a.dist - b.dist);
for (let i = 0; i < Math.min(NEIGHBORS, distances.length); i++) {
const other = distances[i].other;
const edgeKey = point.id < other.id ? `${point.id}-${other.id}` : `${other.id}-${point.id}`;
currentEdges.add(edgeKey);
}
}
return currentEdges;
};
export const updateEdges = (
edges: Map<string, EdgeState>,
currentEdges: Set<string>,
deltaTime: number,
) => {
// Mark edges that should disappear
for (const [key, edge] of edges) {
if (!currentEdges.has(key) && edge.phase !== EdgePhase.Disappearing) {
edge.phase = EdgePhase.Disappearing;
edge.progress = 0;
}
}
// Add new edges or resurrect disappearing ones
for (const key of currentEdges) {
const existing = edges.get(key);
if (!existing) {
const [id1, id2] = key.split("-").map(Number);
edges.set(key, { id1, id2, phase: EdgePhase.Appearing, progress: 0 });
} else if (existing.phase === EdgePhase.Disappearing) {
existing.phase = EdgePhase.Appearing;
existing.progress = 1 - existing.progress;
}
}
// Animate edges and remove fully disappeared ones
const toRemove: string[] = [];
for (const [key, edge] of edges) {
if (edge.phase === EdgePhase.Appearing) {
edge.progress += EDGE_APPEAR_SPEED * deltaTime;
if (edge.progress >= 1) {
edge.progress = 1;
edge.phase = EdgePhase.Visible;
}
} else if (edge.phase === EdgePhase.Disappearing) {
edge.progress += EDGE_DISAPPEAR_SPEED * deltaTime;
if (edge.progress >= 1) {
toRemove.push(key);
}
}
}
for (const key of toRemove) {
edges.delete(key);
}
};
const getEdgeColor = (id1: number, id2: number): Color => {
const colorIndex = (id1 + id2) % 3;
return colorIndex === 0 ? CYAN : colorIndex === 1 ? VIOLET : BLUE;
};
const drawLine = (
ctx: CanvasRenderingContext2D,
x1: number,
y1: number,
x2: number,
y2: number,
color: Color,
opacity: number,
) => {
ctx.strokeStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${opacity})`;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
};
export const drawEdges = (
ctx: CanvasRenderingContext2D,
edges: Map<string, EdgeState>,
points: Point[],
scaleX: number,
scaleY: number,
) => {
ctx.lineWidth = 1;
for (const [, edge] of edges) {
const p1 = points[edge.id1];
const p2 = points[edge.id2];
const x1 = p1.x * scaleX;
const y1 = p1.y * scaleY;
const x2 = p2.x * scaleX;
const y2 = p2.y * scaleY;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const dist = Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
const maxDist = 200;
const baseOpacity = Math.max(0, 1 - dist / maxDist) * 0.6;
const color = getEdgeColor(edge.id1, edge.id2);
if (edge.phase === EdgePhase.Appearing) {
const t = edge.progress;
// First half: draw from p1 towards center
const seg1End = Math.min(t * 2, 1);
drawLine(ctx, x1, y1, x1 + (cx - x1) * seg1End, y1 + (cy - y1) * seg1End, color, baseOpacity);
// Second half: draw from p2 towards center
if (t > 0.5) {
const seg2End = (t - 0.5) * 2;
drawLine(
ctx,
x2,
y2,
x2 + (cx - x2) * (1 - seg2End),
y2 + (cy - y2) * (1 - seg2End),
color,
baseOpacity,
);
}
} else if (edge.phase === EdgePhase.Disappearing) {
const t = edge.progress;
const len = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const dirX = len > 0 ? (x2 - x1) / len : 0;
const dirY = len > 0 ? (y2 - y1) / len : 0;
if (t < 0.5) {
// First half: gap grows from center
const gapSize = t * 2;
const halfLen = Math.sqrt((cx - x1) ** 2 + (cy - y1) ** 2);
const gapHalf = halfLen * gapSize;
drawLine(ctx, x1, y1, cx - dirX * gapHalf, cy - dirY * gapHalf, color, baseOpacity);
drawLine(ctx, cx + dirX * gapHalf, cy + dirY * gapHalf, x2, y2, color, baseOpacity);
} else {
// Second half: segments shrink and fade
const shrink = (t - 0.5) * 2;
const fadeOpacity = baseOpacity * (1 - shrink);
drawLine(
ctx,
x1,
y1,
x1 + (cx - x1) * (1 - shrink),
y1 + (cy - y1) * (1 - shrink),
color,
fadeOpacity,
);
drawLine(
ctx,
x2 + (cx - x2) * (1 - shrink),
y2 + (cy - y2) * (1 - shrink),
x2,
y2,
color,
fadeOpacity,
);
}
} else {
// Visible - draw full line
drawLine(ctx, x1, y1, x2, y2, color, baseOpacity);
}
}
};
export const drawPoints = (
ctx: CanvasRenderingContext2D,
points: Point[],
scaleX: number,
scaleY: number,
) => {
for (const point of points) {
const x = point.x * scaleX;
const y = point.y * scaleY;
const color = point.id % 2 === 0 ? CYAN : VIOLET;
ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
}
};
@@ -0,0 +1,14 @@
import type { Color } from "./types";
export const DRIFT_AMOUNT = 25;
export const NEIGHBORS = 3;
export const PROXIMITY_THRESHOLD = 250;
export const REPEL_STRENGTH = 80;
export const SPRING_STIFFNESS = 180;
export const SPRING_DAMPING = 12;
export const SPRING_MASS = 1;
export const EDGE_APPEAR_SPEED = 4;
export const EDGE_DISAPPEAR_SPEED = 5;
export const CYAN: Color = { r: 68, g: 211, b: 220 };
export const VIOLET: Color = { r: 145, g: 82, b: 211 };
export const BLUE: Color = { r: 107, g: 147, b: 216 };
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { drawEdges, drawPoints, findCurrentEdges, updateEdges, updatePoints } from "./animation";
import { INITIAL_POINTS } from "./points";
import type { EdgeState, Point } from "./types";
export const DynamicCanvas = () => {
const [isTouchDevice, setIsTouchDevice] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const pointsRef = useRef<Point[]>(INITIAL_POINTS.map((p) => ({ ...p })));
const edgesRef = useRef<Map<string, EdgeState>>(new Map());
const cursorRef = useRef({ x: 0, y: 0 });
const lastTimeRef = useRef(0);
useEffect(() => {
if (typeof window !== "undefined") {
setIsTouchDevice(window.matchMedia("(pointer: coarse)").matches);
}
}, []);
useEffect(() => {
if (isTouchDevice) return;
const handleMouseMove = (e: MouseEvent) => {
cursorRef.current.x = e.clientX;
cursorRef.current.y = e.clientY;
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [isTouchDevice]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationId: number;
const startTime = performance.now();
lastTimeRef.current = startTime;
const animate = (time: number) => {
const elapsed = time - startTime;
const deltaTime = Math.min((time - lastTimeRef.current) / 1000, 0.1);
lastTimeRef.current = time;
const points = pointsRef.current;
const edges = edgesRef.current;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const targetWidth = Math.round(rect.width * dpr);
const targetHeight = Math.round(rect.height * dpr);
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
canvas.width = targetWidth;
canvas.height = targetHeight;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const scaleX = rect.width / 1000;
const scaleY = rect.height / 500;
updatePoints(
points,
elapsed,
deltaTime,
cursorRef.current,
rect,
scaleX,
scaleY,
isTouchDevice,
);
const currentEdges = findCurrentEdges(points);
updateEdges(edges, currentEdges, deltaTime);
ctx.clearRect(0, 0, rect.width, rect.height);
drawEdges(ctx, edges, points, scaleX, scaleY);
drawPoints(ctx, points, scaleX, scaleY);
animationId = requestAnimationFrame(animate);
};
animationId = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(animationId);
};
}, [isTouchDevice]);
return <canvas ref={canvasRef} className="h-full w-full" />;
};
@@ -0,0 +1,26 @@
"use client";
import { useReducedMotion } from "motion/react";
import { DynamicCanvas } from "./dynamic-canvas";
import { StaticFallback } from "./static-fallback";
type Props = {
wrapperHeight?: string;
canvasHeight?: string;
};
export const GeometricElement = ({ wrapperHeight = "0px", canvasHeight = "100vh" }: Props) => {
const shouldReduceMotion = useReducedMotion();
const isReducedMotion = shouldReduceMotion ?? false;
return (
<div className="relative w-full overflow-visible" style={{ height: wrapperHeight }}>
<div
className="-z-10 -translate-y-1/2 mask-[linear-gradient(to_bottom,transparent,black_50%,transparent)] absolute inset-x-0 top-1/2"
style={{ height: canvasHeight }}
>
{isReducedMotion ? <StaticFallback /> : <DynamicCanvas />}
</div>
</div>
);
};
@@ -0,0 +1,39 @@
import type { Point } from "./types";
const seededRandom = (seed: number) => {
const x = Math.sin(seed * 9999) * 10000;
return x - Math.floor(x);
};
export const generatePoints = (): Point[] => {
const points: Point[] = [];
const numPoints = 40;
for (let i = 0; i < numPoints; i++) {
const rand = (offset: number) => seededRandom(i * 100 + offset);
const baseX = 50 + rand(1) * 900;
const baseY = 30 + rand(2) * 440;
points.push({
id: i,
baseX,
baseY,
driftPhaseX: rand(3) * Math.PI * 2,
driftPhaseY: rand(4) * Math.PI * 2,
driftSpeedX: 0.00015 + rand(5) * 0.00025,
driftSpeedY: 0.00015 + rand(6) * 0.00025,
x: baseX,
y: baseY,
velocityX: 0,
velocityY: 0,
targetOffsetX: 0,
targetOffsetY: 0,
currentOffsetX: 0,
currentOffsetY: 0,
});
}
return points;
};
export const INITIAL_POINTS = generatePoints();
@@ -0,0 +1,58 @@
"use client";
import { NEIGHBORS } from "./constants";
import { INITIAL_POINTS } from "./points";
import type { Point } from "./types";
export const StaticFallback = () => {
const points = INITIAL_POINTS;
const edges: Array<{ p1: Point; p2: Point }> = [];
for (const point of points) {
const distances = points
.filter((p) => p.id !== point.id)
.map((other) => ({
other,
dist: Math.sqrt((point.baseX - other.baseX) ** 2 + (point.baseY - other.baseY) ** 2),
}))
.sort((a, b) => a.dist - b.dist);
for (let i = 0; i < Math.min(NEIGHBORS, distances.length); i++) {
const other = distances[i].other;
if (point.id < other.id) {
edges.push({ p1: point, p2: other });
}
}
}
return (
<svg
viewBox="0 0 1000 500"
className="h-full w-full"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
{edges.map(({ p1, p2 }) => (
<line
key={`${p1.id}-${p2.id}`}
x1={p1.baseX}
y1={p1.baseY}
x2={p2.baseX}
y2={p2.baseY}
stroke={(p1.id + p2.id) % 2 === 0 ? "var(--accent-cyan)" : "var(--accent-violet)"}
strokeWidth={1}
opacity={0.4}
/>
))}
{points.map((point) => (
<circle
key={point.id}
cx={point.baseX}
cy={point.baseY}
r={2}
fill={point.id % 2 === 0 ? "var(--accent-cyan)" : "var(--accent-violet)"}
/>
))}
</svg>
);
};
@@ -0,0 +1,32 @@
export type Point = {
id: number;
baseX: number;
baseY: number;
driftPhaseX: number;
driftPhaseY: number;
driftSpeedX: number;
driftSpeedY: number;
x: number;
y: number;
velocityX: number;
velocityY: number;
targetOffsetX: number;
targetOffsetY: number;
currentOffsetX: number;
currentOffsetY: number;
};
export enum EdgePhase {
Appearing = 0,
Visible = 1,
Disappearing = 2,
}
export type EdgeState = {
id1: number;
id2: number;
phase: EdgePhase;
progress: number;
};
export type Color = { r: number; g: number; b: number };
+6 -3
View File
@@ -1,4 +1,5 @@
import { LandingClient } from "./landing-client";
import { GeometricElement } from "./geometric-element";
import { Logotype } from "./logotype";
const letterSegments: Record<string, [number, number, number, number][]> = {
l: [
@@ -111,8 +112,10 @@ const allPaths = letterLayout.reduce<{ d: string; key: string }[]>(
export const Landing = () => {
return (
<section id="landing">
<LandingClient paths={allPaths} />
<section id="landing" className="relative flex flex-col">
<Logotype paths={allPaths} />
<GeometricElement />
</section>
);
};
@@ -13,7 +13,7 @@ const LogotypePath = ({
d: string;
scrollProgress: MotionValue<number>;
}) => {
const strokeDashoffset = useTransform(scrollProgress, [0, 0.6], [0, 1]);
const strokeDashoffset = useTransform(scrollProgress, [0, 0.5], [0, 1]);
return (
<m.path
@@ -33,16 +33,16 @@ type Props = {
paths: { d: string; key: string }[];
};
export const LandingClient = ({ paths }: Props) => {
const containerRef = useRef<HTMLDivElement>(null);
export const Logotype = ({ paths }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
target: ref,
offset: ["start start", "end start"],
});
return (
<div ref={containerRef} className="flex min-h-screen w-full items-center justify-center">
<div ref={ref} className="flex min-h-screen w-full items-center justify-center">
<m.svg
role="img"
aria-label="luisdralves"
@@ -53,9 +53,54 @@ export const RadialReveal = ({ targetSection, origin, onComplete }: Props) => {
overlayRef.current.innerHTML = "";
overlayRef.current.appendChild(clone);
setIsReady(true);
}, [targetSection]);
// Continuously sync canvas content during the reveal animation
useEffect(() => {
if (!isReady || hasCompleted) return;
const overlay = overlayRef.current;
if (!overlay) return;
// Cache canvas pairs once at effect start
const allCanvases = document.querySelectorAll("canvas");
const originalCanvases: HTMLCanvasElement[] = [];
for (const canvas of allCanvases) {
if (!overlay.contains(canvas)) {
originalCanvases.push(canvas as HTMLCanvasElement);
}
}
const clonedCanvases = Array.from(overlay.querySelectorAll("canvas")) as HTMLCanvasElement[];
let animationId: number;
const syncCanvases = () => {
originalCanvases.forEach((original, index) => {
const cloned = clonedCanvases[index];
if (!cloned || original.width === 0 || original.height === 0) return;
const clonedCtx = cloned.getContext("2d");
if (!clonedCtx) return;
if (cloned.width !== original.width) cloned.width = original.width;
if (cloned.height !== original.height) cloned.height = original.height;
clonedCtx.clearRect(0, 0, cloned.width, cloned.height);
clonedCtx.drawImage(original, 0, 0);
});
animationId = requestAnimationFrame(syncCanvases);
};
animationId = requestAnimationFrame(syncCanvases);
return () => {
cancelAnimationFrame(animationId);
};
}, [isReady, hasCompleted]);
const maxRadius =
typeof window !== "undefined"
? Math.hypot(