Skip to content

Commit

Permalink
Merge pull request #7 from keksiqc/feat/backgrounds
Browse files Browse the repository at this point in the history
feat/backgrounds
  • Loading branch information
keksiqc authored Mar 2, 2025
2 parents c63cfd0 + 2073c7d commit 51501c3
Show file tree
Hide file tree
Showing 8 changed files with 357 additions and 1 deletion.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ The `background` option supports multiple styles:
- `'grid'`: Regular grid pattern
- `'dashed-grid'`: Dashed grid pattern
- `'animated'`: Animated gradient pattern
- `'flickering-grid'`: Grid pattern with squares that randomly change opacity for a dynamic effect
- `'animated-grid'`: Grid pattern with squares that animate in and out at random positions
- `'none'`: No background pattern (default)

#### Custom Backgrounds
Expand Down
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.477.0",
"motion": "^12.4.7",
"p5i": "^0.6.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
2 changes: 1 addition & 1 deletion shako.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const config: Config = {
discordID: '527147599942385674', // Discord user ID for Lanyard integration (can be omitted if 'user' is defined)
lanyardUrl: 'api.lanyard.rest/', // Custom Lanyard API URL (optional, for self-hosted instances)
borderRadius: 0.5, // Border radius (default: 0.5, recommended: 0, 0.3, 0.5, 0.75, 1)
background: 'dot', // Background style (default: 'none') [dot, grid, dashed-grid, animated, none]
background: 'flickering-grid', // Background style (default: 'none') [dot, grid, dashed-grid, flickering-grid, animated-grid, animated, none]
footer: true, // Whether to show the footer (default: true)
// footer: 'Made with Heart', // Custom footer text (optional)
iconButtons: [
Expand Down
6 changes: 6 additions & 0 deletions src/components/BackgroundFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { lazy, Suspense } from 'react'
const AnimatedBackground = lazy(() => import('@/components/ui/animated-pattern'))
const DotPattern = lazy(() => import('@/components/ui/dot-pattern'))
const GridPattern = lazy(() => import('@/components/ui/grid-pattern'))
const FlickeringGridPattern = lazy(() => import('@/components/ui/flickering-grid-pattern'))
const AnimatedGridPattern = lazy(() => import('@/components/ui/animated-grid-pattern'))
const CustomBackground = lazy(() => import('@/components/ui/custom-background'))

interface BackgroundFactoryProps extends Pick<Config, 'background' |
Expand Down Expand Up @@ -40,6 +42,10 @@ export function BackgroundFactory({
return <GridPattern />
case 'dashed-grid':
return <GridPattern strokeDasharray="4 2" />
case 'flickering-grid':
return <FlickeringGridPattern />
case 'animated-grid':
return <AnimatedGridPattern />
case 'image':
return <CustomBackground preset={{ type: 'image', image: backgroundImage }} />
case 'color':
Expand Down
161 changes: 161 additions & 0 deletions src/components/ui/animated-grid-pattern.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
'use client'

import type {
ComponentPropsWithoutRef,
} from 'react'
import { cn } from '@/lib/utils'

import { motion } from 'motion/react'
import {
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react'

interface Square {
id: string
pos: [number, number]
}

export interface AnimatedGridPatternProps
extends ComponentPropsWithoutRef<'svg'> {
width?: number
height?: number
x?: number
y?: number
strokeDasharray?: any
numSquares?: number
maxOpacity?: number
duration?: number
repeatDelay?: number
}

export default function AnimatedGridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = 0,
numSquares = 50,
className,
maxOpacity = 0.1,
duration = 4,
...props
}: AnimatedGridPatternProps) {
const id = useId()
const containerRef = useRef(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })

const getPos = useCallback((): [number, number] => {
return [
Math.floor((Math.random() * dimensions.width) / width),
Math.floor((Math.random() * dimensions.height) / height),
]
}, [dimensions.width, dimensions.height, width, height])

// Adjust the generateSquares function to return objects with an id, x, and y
const generateSquares = useCallback((count: number): Square[] => {
return Array.from({ length: count }, (_, i) => ({
id: `${id}-square-${i}`,
pos: getPos(),
}))
}, [getPos, id])

const [squares, setSquares] = useState<Square[]>(() => generateSquares(numSquares))

// Function to update a single square's position
const updateSquarePosition = useCallback((id: string) => {
setSquares((currentSquares: Square[]) =>
currentSquares.map((sq: Square) =>
sq.id === id
? {
...sq,
pos: getPos(),
}
: sq,
),
)
}, [getPos])

// Update squares to animate in
useEffect(() => {
if (dimensions.width && dimensions.height) {
setSquares(generateSquares(numSquares))
}
}, [dimensions, numSquares, generateSquares])

// Resize observer to update container dimensions
useEffect(() => {
const container = containerRef.current
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
}
})

if (container) {
resizeObserver.observe(container)
}

return () => {
resizeObserver.disconnect()
}
}, [])

return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
'pointer-events-none fixed inset-0 z-[-1] size-full border border-neutral-400/15 fill-neutral-400/15 stroke-neutral-400/15',
className,
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
<svg x={x} y={y} className="overflow-visible">
{squares.map(({ pos: [x, y], id }: Square, index: number) => (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: maxOpacity }}
transition={{
duration,
repeat: 1,
delay: index * 0.1,
repeatType: 'reverse',
}}
onAnimationComplete={() => updateSquarePosition(id)}
key={id}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
fill="currentColor"
strokeWidth="0"
/>
))}
</svg>
</svg>
)
}
184 changes: 184 additions & 0 deletions src/components/ui/flickering-grid-pattern.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
'use client'

import { cn } from '@/lib/utils'
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react'

interface FlickeringGridPatternProps extends React.HTMLAttributes<HTMLDivElement> {
squareSize?: number
gridGap?: number
flickerChance?: number
width?: number
height?: number
className?: string
maxOpacity?: number
}

const FlickeringGridPattern: React.FC<FlickeringGridPatternProps> = ({
squareSize = 4,
gridGap = 6,
flickerChance = 0.3,
width,
height,
className,
maxOpacity = 0.15,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })

const setupCanvas = useCallback(
(canvas: HTMLCanvasElement, width: number, height: number) => {
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const cols = Math.floor(width / (squareSize + gridGap))
const rows = Math.floor(height / (squareSize + gridGap))

const squares = new Float32Array(cols * rows)
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity
}

return { cols, rows, squares, dpr }
},
[squareSize, gridGap, maxOpacity],
)

const updateSquares = useCallback(
(squares: Float32Array, deltaTime: number) => {
for (let i = 0; i < squares.length; i++) {
if (Math.random() < flickerChance * deltaTime) {
squares[i] = Math.random() * maxOpacity
}
}
},
[flickerChance, maxOpacity],
)

const drawGrid = useCallback(
(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
cols: number,
rows: number,
squares: Float32Array,
dpr: number,
) => {
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = 'transparent'
ctx.fillRect(0, 0, width, height)

for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const opacity = squares[i * rows + j]
ctx.fillStyle = `rgba(209, 213, 219, ${opacity})`
ctx.fillRect(
i * (squareSize + gridGap) * dpr,
j * (squareSize + gridGap) * dpr,
squareSize * dpr,
squareSize * dpr,
)
}
}
},
[squareSize, gridGap],
)

useEffect(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container)
return

const ctx = canvas.getContext('2d')
if (!ctx)
return

let animationFrameId: number
let gridParams: ReturnType<typeof setupCanvas>

const updateCanvasSize = () => {
const newWidth = width || container.clientWidth
const newHeight = height || container.clientHeight
setCanvasSize({ width: newWidth, height: newHeight })
gridParams = setupCanvas(canvas, newWidth, newHeight)
}

updateCanvasSize()

let lastTime = 0
const animate = (time: number) => {
if (!isInView)
return

const deltaTime = (time - lastTime) / 1000
lastTime = time

updateSquares(gridParams.squares, deltaTime)
drawGrid(
ctx,
canvas.width,
canvas.height,
gridParams.cols,
gridParams.rows,
gridParams.squares,
gridParams.dpr,
)
animationFrameId = requestAnimationFrame(animate)
}

const resizeObserver = new ResizeObserver(() => {
updateCanvasSize()
})

resizeObserver.observe(container)

const intersectionObserver = new IntersectionObserver(
([entry]) => {
setIsInView(entry.isIntersecting)
},
{ threshold: 0 },
)

intersectionObserver.observe(canvas)

if (isInView) {
animationFrameId = requestAnimationFrame(animate)
}

return () => {
cancelAnimationFrame(animationFrameId)
resizeObserver.disconnect()
intersectionObserver.disconnect()
}
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView])

return (
<div
ref={containerRef}
className={cn('fixed inset-0 z-[-1] size-full border border-neutral-400/15', className)}
{...props}
>
<canvas
ref={canvasRef}
className="pointer-events-none"
style={{
width: canvasSize.width,
height: canvasSize.height,
}}
/>
</div>
)
}

export default FlickeringGridPattern
Loading

0 comments on commit 51501c3

Please sign in to comment.