Files
supabase/apps/www/components/Wrapped/Pages/Home.tsx
Ivan Vasilov 56de26fe22 chore: Migrate the monorepo to use Tailwind v4 (#45318)
This PR migrates the whole monorepo to use Tailwind v4:
- Removed `@tailwindcss/container-queries` plugin since it's included by
default in v4,
- Bump all instances of Tailwind to v4. Made minimal changes to the
shared config to remove non-supported features (`alpha` mentions),
- Migrate all apps to be compatible with v4 configs,
- Fix the `typography.css` import in 3 apps,
- Add missing rules which were included by default in v3,
- Run `pnpm dlx @tailwindcss/upgrade` on all apps, which renames a lot
of classes
- Rename all misnamed classes according to
https://tailwindcss.com/docs/upgrade-guide#renamed-utilities in all
apps.

---------

Co-authored-by: Jordi Enric <jordi.err@gmail.com>
2026-04-30 10:53:24 +00:00

246 lines
7.3 KiB
TypeScript

'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
import { useBreakpoint } from 'common'
import { AnimatedGridBackground } from '../AnimatedGridBackground'
const stats = [
{ label: 'GitHub stars', value: '+10,000' },
{ label: 'Databases created', value: '15M+' },
{ label: 'Realtime messages', value: '280B+' },
{ label: 'Edge Functions invoked', value: '49B+' },
{ label: 'Petabytes stored', value: '64 PB' },
{ label: 'Peak connections', value: '193K' },
{ label: 'Projects created', value: '12M+' },
{ label: 'Images transformed', value: '64M+' },
]
// Animation timing configuration
const BUBBLE_CONFIG = {
// Lifetime
lifetimeMin: 5000, // minimum bubble lifetime in ms
lifetimeMax: 10000, // maximum bubble lifetime in ms
// Spawn timing
spawnIntervalMin: 2000, // minimum time between spawns in ms
spawnIntervalMax: 3500, // maximum time between spawns in ms
initialDelays: [1200, 1300, 1100, 1400], // staggered initial spawn delays (3-4 bubbles)
maxBubbles: 4, // maximum bubbles visible at once
// Positioning
minDistance: 18, // minimum distance between bubble centers (percentage)
}
type FloatingStatBubble = {
id: number
stat: (typeof stats)[number]
top: number
left: number
}
function getDistance(
box1: { top: number; left: number },
box2: { top: number; left: number }
): number {
return Math.sqrt(Math.pow(box1.top - box2.top, 2) + Math.pow(box1.left - box2.left, 2))
}
function generateNonOverlappingPosition(
existingBubbles: FloatingStatBubble[],
isMobile: boolean
): { top: number; left: number } | null {
const maxAttempts = 20
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const top = isMobile
? Math.random() * 50 + 10 // 10-60% on mobile
: Math.random() * 70 + 10 // 10-80% on desktop
// On mobile, use a narrower, more centered range to prevent overflow
const left = isMobile
? Math.random() * 20 + 20 // 20-40% on mobile
: Math.random() * 70 + 10 // 10-80% on desktop
const hasOverlap = existingBubbles.some(
(bubble) =>
getDistance({ top, left }, { top: bubble.top, left: bubble.left }) <
BUBBLE_CONFIG.minDistance
)
if (!hasOverlap) {
return { top, left }
}
}
return null
}
function generateRandomBubble(
id: number,
existingBubbles: FloatingStatBubble[],
usedStatIndices: Set<number>,
isMobile: boolean
): FloatingStatBubble | null {
const position = generateNonOverlappingPosition(existingBubbles, isMobile)
if (!position) {
return null
}
// Find an unused stat, or pick a random one if all have been used
let statIndex: number
const availableIndices = stats.map((_, i) => i).filter((i) => !usedStatIndices.has(i))
if (availableIndices.length > 0) {
statIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
} else {
statIndex = Math.floor(Math.random() * stats.length)
}
return {
id,
stat: stats[statIndex],
top: position.top,
left: position.left,
}
}
function FloatingStatBubbles() {
const [bubbles, setBubbles] = useState<FloatingStatBubble[]>([])
const bubbleIdRef = useRef(0)
const usedStatIndicesRef = useRef<Set<number>>(new Set())
const lifetimeTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
const isMobile = useBreakpoint(768)
useEffect(() => {
const spawnBubble = () => {
setBubbles((prev) => {
// Limit max bubbles on screen
if (prev.length >= BUBBLE_CONFIG.maxBubbles) {
return prev
}
const id = bubbleIdRef.current++
const newBubble = generateRandomBubble(id, prev, usedStatIndicesRef.current, isMobile)
if (!newBubble) {
return prev
}
// Track which stat index was used
const statIndex = stats.indexOf(newBubble.stat)
usedStatIndicesRef.current.add(statIndex)
const lifetime =
Math.random() * (BUBBLE_CONFIG.lifetimeMax - BUBBLE_CONFIG.lifetimeMin) +
BUBBLE_CONFIG.lifetimeMin
const timerId = setTimeout(() => {
setBubbles((current) => current.filter((bubble) => bubble.id !== id))
usedStatIndicesRef.current.delete(statIndex)
lifetimeTimersRef.current.delete(timerId)
}, lifetime)
lifetimeTimersRef.current.add(timerId)
return [...prev, newBubble]
})
}
const initialTimers = BUBBLE_CONFIG.initialDelays.map((delay) => setTimeout(spawnBubble, delay))
let intervalId: ReturnType<typeof setTimeout>
const scheduleNextSpawn = () => {
intervalId = setTimeout(
() => {
spawnBubble()
scheduleNextSpawn()
},
BUBBLE_CONFIG.spawnIntervalMin +
Math.random() * (BUBBLE_CONFIG.spawnIntervalMax - BUBBLE_CONFIG.spawnIntervalMin)
)
}
scheduleNextSpawn()
return () => {
initialTimers.forEach(clearTimeout)
clearTimeout(intervalId)
lifetimeTimersRef.current.forEach(clearTimeout)
lifetimeTimersRef.current.clear()
}
}, [isMobile])
return (
<AnimatePresence>
{bubbles.map((bubble) => (
<motion.div
key={bubble.id}
className={cn(
'absolute pointer-events-none px-3 py-2 bg-surface-100 border rounded-lg shadow-xs'
)}
style={{
top: `${bubble.top}%`,
left: `${bubble.left}%`,
}}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 0.98, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
opacity: { duration: 0.75, type: 'spring' },
scale: {
type: 'spring',
duration: 0.7,
bounce: 0.15,
},
}}
>
<p className="text-sm font-mono font-medium text-brand-link dark:text-brand whitespace-nowrap">
{bubble.stat.value}
</p>
<p className="text-xs text-foreground-light whitespace-nowrap">{bubble.stat.label}</p>
</motion.div>
))}
</AnimatePresence>
)
}
export function Home() {
return (
<div className="h-[calc(100dvh-64px)] flex flex-col max-w-240 mx-auto w-[95%] md:w-full">
<section className="relative border-x border-b h-full">
{/* Grid background */}
<AnimatedGridBackground
cols={5}
rows={{
desktop: 4,
mobile: 8,
}}
tiles={[
{ cell: 1, type: 'dots' },
{ cell: 6, type: 'dots' },
{ cell: 7, type: 'stripes' },
{ cell: 8, type: 'stripes' },
{ cell: 13, type: 'dots' },
{ cell: 18, type: 'dots' },
]}
/>
{/* Floating stat bubbles */}
<FloatingStatBubbles />
{/* Content */}
<div className="flex flex-col justify-end h-full px-4 lg:px-8 py-0 relative">
<h1 className="font-medium tracking-tighter text-6xl md:text-7xl lg:text-[5.6rem] translate-y-2 lg:translate-y-[10px]">
Supabase Wrapped
</h1>
</div>
</section>
<div className="relative w-full border-x border-b px-6 lg:px-10 py-12">
<article className="text-lg">
<p>You created more Supabase databases in 2025 than in all previous years combined.</p>
</article>
</div>
</div>
)
}