Created
June 12, 2026 03:30
-
-
Save aryomuzakki/cde4050921386f4d8501217de0af23b2 to your computer and use it in GitHub Desktop.
Electric Border Card using canvas, work cross-platform (desktop and android/iOS)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { useEffect, useRef, useState } from "react"; | |
| // Pseudo-random function | |
| function random(x: number): number { | |
| return (Math.sin(x * 12.9898) * 43758.5453) % 1; | |
| } | |
| // 2D Noise function | |
| function noise2D(x: number, y: number): number { | |
| const i = Math.floor(x); | |
| const j = Math.floor(y); | |
| const fx = x - i; | |
| const fy = y - j; | |
| const a = random(i + j * 57); | |
| const b = random(i + 1 + j * 57); | |
| const c = random(i + (j + 1) * 57); | |
| const d = random(i + 1 + (j + 1) * 57); | |
| const ux = fx * fx * (3.0 - 2.0 * fx); | |
| const uy = fy * fy * (3.0 - 2.0 * fy); | |
| return a * (1 - ux) * (1 - uy) + b * ux * (1 - uy) + c * (1 - ux) * uy + d * ux * uy; | |
| } | |
| // Octaved noise function | |
| function octavedNoise( | |
| x: number, | |
| octaves: number, | |
| lacunarity: number, | |
| gain: number, | |
| baseAmplitude: number, | |
| baseFrequency: number, | |
| time = 0, | |
| seed = 0, | |
| baseFlatness = 1.0, | |
| ): number { | |
| let y = 0; | |
| let amplitude = baseAmplitude; | |
| let frequency = baseFrequency; | |
| for (let i = 0; i < octaves; i++) { | |
| let octaveAmplitude = amplitude; | |
| if (i === 0) { | |
| octaveAmplitude *= baseFlatness; | |
| } | |
| y += octaveAmplitude * noise2D(frequency * x + seed * 100, time * frequency * 0.3); | |
| frequency *= lacunarity; | |
| amplitude *= gain; | |
| } | |
| return y; | |
| } | |
| // Corner point on a circular arc | |
| function getCornerPoint( | |
| centerX: number, | |
| centerY: number, | |
| radius: number, | |
| startAngle: number, | |
| arcLength: number, | |
| progress: number, | |
| ): { x: number; y: number } { | |
| const angle = startAngle + progress * arcLength; | |
| return { | |
| x: centerX + radius * Math.cos(angle), | |
| y: centerY + radius * Math.sin(angle), | |
| }; | |
| } | |
| // Point on a rounded rectangle perimeter | |
| function getRoundedRectPoint( | |
| t: number, | |
| left: number, | |
| top: number, | |
| width: number, | |
| height: number, | |
| radius: number, | |
| ): { x: number; y: number } { | |
| const straightWidth = width - 2 * radius; | |
| const straightHeight = height - 2 * radius; | |
| const cornerArc = (Math.PI * radius) / 2; | |
| const totalPerimeter = 2 * straightWidth + 2 * straightHeight + 4 * cornerArc; | |
| const distance = t * totalPerimeter; | |
| let accumulated = 0; | |
| if (distance <= accumulated + straightWidth) { | |
| const progress = (distance - accumulated) / straightWidth; | |
| return { x: left + radius + progress * straightWidth, y: top }; | |
| } | |
| accumulated += straightWidth; | |
| if (distance <= accumulated + cornerArc) { | |
| const progress = (distance - accumulated) / cornerArc; | |
| return getCornerPoint( | |
| left + width - radius, | |
| top + radius, | |
| radius, | |
| -Math.PI / 2, | |
| Math.PI / 2, | |
| progress, | |
| ); | |
| } | |
| accumulated += cornerArc; | |
| if (distance <= accumulated + straightHeight) { | |
| const progress = (distance - accumulated) / straightHeight; | |
| return { x: left + width, y: top + radius + progress * straightHeight }; | |
| } | |
| accumulated += straightHeight; | |
| if (distance <= accumulated + cornerArc) { | |
| const progress = (distance - accumulated) / cornerArc; | |
| return getCornerPoint( | |
| left + width - radius, | |
| top + height - radius, | |
| radius, | |
| 0, | |
| Math.PI / 2, | |
| progress, | |
| ); | |
| } | |
| accumulated += cornerArc; | |
| if (distance <= accumulated + straightWidth) { | |
| const progress = (distance - accumulated) / straightWidth; | |
| return { x: left + width - radius - progress * straightWidth, y: top + height }; | |
| } | |
| accumulated += straightWidth; | |
| if (distance <= accumulated + cornerArc) { | |
| const progress = (distance - accumulated) / cornerArc; | |
| return getCornerPoint( | |
| left + radius, | |
| top + height - radius, | |
| radius, | |
| Math.PI / 2, | |
| Math.PI / 2, | |
| progress, | |
| ); | |
| } | |
| accumulated += cornerArc; | |
| if (distance <= accumulated + straightHeight) { | |
| const progress = (distance - accumulated) / straightHeight; | |
| return { x: left, y: top + height - radius - progress * straightHeight }; | |
| } | |
| accumulated += straightHeight; | |
| const progress = (distance - accumulated) / cornerArc; | |
| return getCornerPoint(left + radius, top + radius, radius, Math.PI, Math.PI / 2, progress); | |
| } | |
| export default function ElectricBorderCard({ children }: { children: React.ReactNode }) { | |
| const canvasRef = useRef<HTMLCanvasElement | null>(null); | |
| const containerRef = useRef<HTMLDivElement | null>(null); | |
| const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); | |
| const octaves = 10; | |
| const lacunarity = 1.6; | |
| const gain = 0.7; | |
| const amplitude = 0.095; | |
| const frequency = 10; | |
| const baseFlatness = 0; | |
| const displacement = 60; | |
| const speed = 1.5; | |
| const borderOffset = 40; // Reduced from 60 to fit smaller cards better | |
| const borderRadius = 24; | |
| const lineWidth = 1; | |
| const color = "#fedb4e"; // Jackpot gold color | |
| useEffect(() => { | |
| if (!containerRef.current) return; | |
| const observer = new ResizeObserver((entries) => { | |
| for (const entry of entries) { | |
| setDimensions({ | |
| width: entry.contentRect.width + borderOffset * 2, // canvas is larger than the card | |
| height: entry.contentRect.height + borderOffset * 2, | |
| }); | |
| } | |
| }); | |
| observer.observe(containerRef.current); | |
| return () => observer.disconnect(); | |
| }, [borderOffset]); | |
| useEffect(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas || dimensions.width === 0 || dimensions.height === 0) return; | |
| const ctx = canvas.getContext("2d"); | |
| if (!ctx) return; | |
| canvas.width = dimensions.width; | |
| canvas.height = dimensions.height; | |
| let time = 0; | |
| let lastFrameTime = 0; | |
| let animationId: number; | |
| const draw = (currentTime = 0) => { | |
| const deltaTime = (currentTime - lastFrameTime) / 1000; | |
| time += deltaTime * speed; | |
| lastFrameTime = currentTime; | |
| ctx.clearRect(0, 0, dimensions.width, dimensions.height); | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = lineWidth; | |
| ctx.lineCap = "round"; | |
| ctx.lineJoin = "round"; | |
| const scale = displacement; | |
| const left = borderOffset; | |
| const top = borderOffset; | |
| const borderWidth = dimensions.width - 2 * borderOffset; | |
| const borderHeight = dimensions.height - 2 * borderOffset; | |
| const maxRadius = Math.min(borderWidth, borderHeight) / 2; | |
| const radius = Math.min(borderRadius, maxRadius); | |
| const approximatePerimeter = 2 * (borderWidth + borderHeight) + 2 * Math.PI * radius; | |
| const sampleCount = Math.floor(approximatePerimeter / 2); | |
| ctx.beginPath(); | |
| for (let i = 0; i <= sampleCount; i++) { | |
| const progress = i / sampleCount; | |
| const point = getRoundedRectPoint(progress, left, top, borderWidth, borderHeight, radius); | |
| const xNoise = octavedNoise( | |
| progress * 8, | |
| octaves, | |
| lacunarity, | |
| gain, | |
| amplitude, | |
| frequency, | |
| time, | |
| 0, | |
| baseFlatness, | |
| ); | |
| const yNoise = octavedNoise( | |
| progress * 8, | |
| octaves, | |
| lacunarity, | |
| gain, | |
| amplitude, | |
| frequency, | |
| time, | |
| 1, | |
| baseFlatness, | |
| ); | |
| const displacedX = point.x + xNoise * scale; | |
| const displacedY = point.y + yNoise * scale; | |
| if (i === 0) { | |
| ctx.moveTo(displacedX, displacedY); | |
| } else { | |
| ctx.lineTo(displacedX, displacedY); | |
| } | |
| } | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| animationId = requestAnimationFrame(draw); | |
| }; | |
| animationId = requestAnimationFrame(draw); | |
| return () => { | |
| cancelAnimationFrame(animationId); | |
| }; | |
| }, [ | |
| dimensions, | |
| octaves, | |
| lacunarity, | |
| gain, | |
| amplitude, | |
| frequency, | |
| baseFlatness, | |
| displacement, | |
| speed, | |
| borderOffset, | |
| borderRadius, | |
| lineWidth, | |
| color, | |
| ]); | |
| return ( | |
| <div | |
| className="relative mx-auto w-max p-2" | |
| ref={containerRef} | |
| style={ | |
| { | |
| "--electric-border-color": color, | |
| "--electric-light-color": `oklch(from var(--electric-border-color) l c h)`, | |
| "--gradient-color": `oklch(from var(--electric-border-color) 0.3 calc(c / 2) h / 0.4)`, | |
| "--color-neutral-900": `oklch(0.185 0 0)`, | |
| } as React.CSSProperties | |
| } | |
| > | |
| {/* Canvas layer behind the main card */} | |
| <div | |
| className="absolute inset-0 rounded-[24px] opacity-20" | |
| style={ | |
| { | |
| background: `linear-gradient(-30deg, var(--gradient-color), transparent, var(--gradient-color)), linear-gradient(to bottom, var(--color-neutral-900), var(--color-neutral-900))`, | |
| // boxShadow: `inset 0 0 0 2px ${color}`, | |
| } as React.CSSProperties | |
| } | |
| ></div> | |
| <div className="pointer-events-none absolute top-1/2 left-1/2 z-1 -translate-x-1/2 -translate-y-1/2"> | |
| <canvas | |
| ref={canvasRef} | |
| style={{ | |
| width: `${dimensions.width}px`, | |
| height: `${dimensions.height}px`, | |
| }} | |
| /> | |
| </div> | |
| <div className="relative z-10 rounded-[24px] px-4 min-[400px]:px-6 sm:px-8"> | |
| {/* Glow layers */} | |
| {/* <div | |
| className="pointer-events-none absolute inset-0 rounded-[24px] border-2 opacity-20 blur-[1px]" | |
| style={{ borderColor: color }} | |
| /> */} | |
| <div className="pointer-events-none absolute inset-0 rounded-[24px] border-2 border-(--electric-light-color) opacity-65 blur-xs" /> | |
| {/* Content */} | |
| {children} | |
| </div> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment