mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 12:25:05 +03:00
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:
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>
|
||||
|
||||
Reference in new issue
Block a user