Enhance PartnerHero visuals and IsometricGrid animation

Replaces Supabase wordmark logos with the icon in PartnerHero, adds partnerColor prop to IsometricGrid for dynamic coloring, and implements a custom animated canvas grid with interactive hover effects. Also updates PartnerBenefits panel and icon styling for improved visual consistency.
This commit is contained in:
Jonathan Summers-Muir committed 2026-01-08 19:38:27 +08:00
1 parent 95525b1390
commit 692a324fbe
3 files changed
+327 -44

No files matched your search

@@ -1,35 +1,324 @@
export function IsometricGrid() {
'use client'
import { useEffect, useRef, useState } from 'react'
interface IsometricGridProps {
partnerColor?: string
}
export function IsometricGrid({ partnerColor = '#FF9900' }: IsometricGridProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [hoverTarget, setHoverTarget] = useState<{ x: number; y: number } | null>(null)
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
const pendingHoverRef = useRef<{ x: number; y: number } | null>(null)
useEffect(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const resize = () => {
canvas.width = canvas.offsetWidth * 2
canvas.height = canvas.offsetHeight * 2
ctx.scale(2, 2)
}
resize()
window.addEventListener('resize', resize)
const gridSize = 40
const cols = Math.ceil(canvas.offsetWidth / gridSize) + 2
const rows = Math.ceil(canvas.offsetHeight / gridSize) + 2
interface Path {
x: number
y: number
targetX: number
targetY: number
color: string
trail: { x: number; y: number }[]
direction: { dx: number; dy: number }
horizontalSteps: number
mode: 'normal' | 'attracted' | 'circling'
circleStep: number
}
const createPath = (x: number, startY: number, color: string): Path => ({
x: x * gridSize,
y: startY * gridSize,
targetX: x * gridSize,
targetY: (startY + 1) * gridSize,
color,
trail: [],
direction: { dx: 0, dy: 1 },
horizontalSteps: 0,
mode: 'normal',
circleStep: 0,
})
const paths: Path[] = [
createPath(Math.floor(cols * 0.45), 0, '#3ECF8E'),
createPath(Math.floor(cols * 0.55), 0, partnerColor),
]
// Circle pattern around a point (clockwise)
const circlePattern = [
{ dx: 1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: -1 },
]
const drawGrid = () => {
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)
// Draw grid - different color for light/dark mode
const theme = document.documentElement.getAttribute('data-theme')
const isDark = theme === 'dark' || theme?.includes('dark')
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.1)'
ctx.lineWidth = 0.5
for (let x = 0; x <= cols; x++) {
ctx.beginPath()
ctx.moveTo(x * gridSize, 0)
ctx.lineTo(x * gridSize, rows * gridSize)
ctx.stroke()
}
for (let y = 0; y <= rows; y++) {
ctx.beginPath()
ctx.moveTo(0, y * gridSize)
ctx.lineTo(cols * gridSize, y * gridSize)
ctx.stroke()
}
// Draw animated paths
const maxY = rows * gridSize
const fadeStartY = maxY * 0.7
paths.forEach((path) => {
const bottomFade = path.y > fadeStartY ? 1 - (path.y - fadeStartY) / (maxY - fadeStartY) : 1
if (path.trail.length > 1) {
for (let i = 1; i < path.trail.length; i++) {
const prev = path.trail[i - 1]
const curr = path.trail[i]
const trailAlpha = i / path.trail.length
const alpha = trailAlpha * bottomFade
ctx.beginPath()
ctx.strokeStyle = path.color
ctx.lineWidth = 1.5
ctx.lineCap = 'round'
ctx.globalAlpha = alpha
ctx.moveTo(prev.x, prev.y)
ctx.lineTo(curr.x, curr.y)
ctx.stroke()
}
if (path.trail.length > 0) {
const last = path.trail[path.trail.length - 1]
ctx.beginPath()
ctx.strokeStyle = path.color
ctx.lineWidth = 1.5
ctx.lineCap = 'round'
ctx.globalAlpha = bottomFade
ctx.moveTo(last.x, last.y)
ctx.lineTo(path.x, path.y)
ctx.stroke()
}
ctx.globalAlpha = 1
}
})
}
const updatePaths = (target: { x: number; y: number } | null) => {
paths.forEach((path) => {
const speed = 5
const dx = path.targetX - path.x
const dy = path.targetY - path.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < speed) {
path.x = path.targetX
path.y = path.targetY
path.trail.push({ x: path.x, y: path.y })
if (path.trail.length > 15) path.trail.shift()
// Check if we should be attracted to hover target
if (target && path.mode === 'normal' && path.y < target.y) {
// Path is above the target - get attracted
path.mode = 'attracted'
}
if (path.mode === 'attracted' && target) {
// Move towards the target
const toTargetX = target.x - path.x
const toTargetY = target.y - path.y
if (Math.abs(toTargetX) < gridSize && Math.abs(toTargetY) < gridSize) {
// Reached target, start circling
path.mode = 'circling'
path.circleStep = 0
path.x = target.x
path.y = target.y
} else {
// Move towards target
if (Math.abs(toTargetX) > Math.abs(toTargetY)) {
path.direction = { dx: toTargetX > 0 ? 1 : -1, dy: 0 }
} else {
path.direction = { dx: 0, dy: toTargetY > 0 ? 1 : -1 }
}
}
} else if (path.mode === 'circling' && target) {
// Circle around the target
path.direction = circlePattern[path.circleStep % 4]
path.circleStep++
// After a few circles, check if target is still active
if (path.circleStep > 12) {
path.mode = 'normal'
path.direction = { dx: 0, dy: 1 }
}
} else {
// Normal movement - reset mode if no target
if (!target) {
path.mode = 'normal'
}
const isHorizontal = path.direction.dx !== 0
if (isHorizontal) {
path.horizontalSteps++
} else {
path.horizontalSteps = 0
}
const turnChance = Math.random()
const mustTurnDown = path.horizontalSteps >= 3
if (mustTurnDown) {
path.direction = { dx: 0, dy: 1 }
path.horizontalSteps = 0
} else if (turnChance < 0.88) {
// Continue
} else {
const turns = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: 1 },
]
path.direction = turns[Math.floor(Math.random() * turns.length)]
}
}
path.targetX = path.x + path.direction.dx * gridSize
path.targetY = path.y + path.direction.dy * gridSize
if (path.targetX < 0) {
path.targetX = gridSize
path.direction = { dx: 1, dy: 0 }
}
if (path.targetX > cols * gridSize) {
path.targetX = (cols - 1) * gridSize
path.direction = { dx: -1, dy: 0 }
}
if (path.targetY > rows * gridSize) {
const centerX = Math.floor(cols * 0.4 + Math.random() * cols * 0.2)
path.x = centerX * gridSize
path.y = 0
path.targetX = path.x
path.targetY = gridSize
path.trail = []
path.direction = { dx: 0, dy: 1 }
path.mode = 'normal'
}
} else {
path.x += (dx / dist) * speed
path.y += (dy / dist) * speed
}
})
}
let animationId: number
let currentTarget: { x: number; y: number } | null = null
const animate = () => {
updatePaths(currentTarget)
drawGrid()
animationId = requestAnimationFrame(animate)
}
// Handle hover - convert screen coords to grid coords
const handleMouseMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect()
// Account for perspective transform - approximate
const relX = e.clientX - rect.left
const relY = e.clientY - rect.top
// Convert to grid coordinates (rough approximation due to perspective)
const gridX = Math.floor((relX / rect.width) * cols) * gridSize
const gridY = Math.floor((relY / rect.height) * rows * 0.5) * gridSize
pendingHoverRef.current = { x: gridX, y: gridY }
// Start timer if not already running
if (!hoverTimerRef.current) {
hoverTimerRef.current = setTimeout(() => {
if (pendingHoverRef.current) {
currentTarget = pendingHoverRef.current
setHoverTarget(pendingHoverRef.current)
}
}, 500) // 500ms hover delay
}
}
const handleMouseLeave = () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
pendingHoverRef.current = null
currentTarget = null
setHoverTarget(null)
}
container.addEventListener('mousemove', handleMouseMove)
container.addEventListener('mouseleave', handleMouseLeave)
animate()
return () => {
window.removeEventListener('resize', resize)
container.removeEventListener('mousemove', handleMouseMove)
container.removeEventListener('mouseleave', handleMouseLeave)
cancelAnimationFrame(animationId)
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
}
}
}, [partnerColor])
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute inset-0 ![perspective:1000px]">
<div ref={containerRef} className="absolute inset-0 overflow-hidden">
<div className="absolute inset-0 ![perspective:1000px] pointer-events-none">
<div
className="absolute inset-0 w-[200%] h-[200%] -left-1/2 -top-1/2"
style={{ transform: 'rotateX(75deg)' }}
>
<svg className="w-full h-full opacity-[0.15]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern
id="partner-grid"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<path
d="M 40 0 L 0 0 0 40"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
className="text-brand"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#partner-grid)" />
</svg>
<canvas ref={canvasRef} className="w-full h-full" />
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background" />
<div className="absolute inset-0 bg-gradient-to-t from-transparent via-transparent to-background" />
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-background to-transparent pointer-events-none" />
</div>
)
}
@@ -14,16 +14,16 @@ interface PartnerBenefitsProps {
export function PartnerBenefits({ benefits }: PartnerBenefitsProps) {
return (
<SectionContainer className="relative z-10 lg:-mt-16">
<SectionContainer className="relative z-10 lg:-mt-32">
<div className="grid md:grid-cols-3 gap-4">
{benefits.map((benefit, i) => (
<Panel key={i} outerClassName="h-full">
<Panel key={i} outerClassName="h-full hover:shadow-none">
<div className="p-6 flex flex-col gap-3">
{benefit.stat && (
<span className="text-3xl lg:text-4xl text-brand">{benefit.stat}</span>
)}
{benefit.icon && (
<div className="text-foreground-light">{benefit.icon}</div>
<div className="text-brand [&_svg]:stroke-1">{benefit.icon}</div>
)}
<h3 className="text-lg">{benefit.title}</h3>
<p className="text-foreground-lighter text-sm">{benefit.description}</p>
@@ -5,8 +5,7 @@ import SectionContainer from '~/components/Layouts/SectionContainer'
import styles from '~/styles/animations.module.css'
import { IsometricGrid } from './IsometricGrid'
import * as supabaseLogoWordmarkDark from 'common/assets/images/supabase-logo-wordmark--dark.png'
import * as supabaseLogoWordmarkLight from 'common/assets/images/supabase-logo-wordmark--light.png'
import supabaseLogoIcon from 'common/assets/images/supabase-logo-icon.svg'
interface CTA {
label: string
@@ -17,6 +16,7 @@ interface PartnerHeroProps {
partnerName: string
partnerLogo: string
partnerLogoDark?: string
partnerColor?: string
headline: React.ReactNode
subheadline: string
ctas: CTA[]
@@ -26,30 +26,24 @@ export function PartnerHero({
partnerName,
partnerLogo,
partnerLogoDark,
partnerColor,
headline,
subheadline,
ctas,
}: PartnerHeroProps) {
return (
<div className="relative w-full mx-auto pt-16 pb-8 lg:pb-24 overflow-hidden border-b border-b-muted">
<IsometricGrid />
<div className="relative w-full mx-auto pt-16 pb-8 lg:pb-40 overflow-hidden border-b border-b-muted">
<IsometricGrid partnerColor={partnerColor} />
<SectionContainer className="relative z-10">
<div className="flex flex-col text-center items-center">
{/* Dual Logo */}
<div className="flex items-center gap-8 mb-10">
<div className="flex items-center gap-6 mb-10">
<Image
src={supabaseLogoWordmarkDark}
src={supabaseLogoIcon}
alt="Supabase"
width={200}
height={45}
className="hidden dark:block h-10 w-auto"
/>
<Image
src={supabaseLogoWordmarkLight}
alt="Supabase"
width={200}
height={45}
className="dark:hidden h-10 w-auto"
width={40}
height={40}
className="h-10 w-auto"
/>
<span className="text-3xl text-foreground-muted">+</span>