style: replace static grid with animated grid pattern in chart

Use AnimatedGridPattern from 21st.dev with subtle fading squares.
Scoped to CardContent only so the metric tabs and annotation footer
stay clean.
This commit is contained in:
Usman Baig
2026-03-13 13:01:03 +01:00
parent eb0dc4a27b
commit 9a54d93c79
4 changed files with 163 additions and 83 deletions

View File

@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Improved ### Improved
- **Refreshed chart background.** The dashboard chart now has a clean grid line pattern instead of the old dotted background, with a soft fade at the top and bottom edges for a more polished look. - **Refreshed chart background.** The dashboard chart now has an animated grid pattern instead of the old dotted background. Subtle squares gently fade in and out across the chart area, with soft edges at the top and bottom for a polished look.
- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data. - **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data.
- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait. - **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait.
- **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs. - **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs.

View File

@@ -11,7 +11,7 @@ import { Checkbox } from '@ciphera-net/ui'
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react' import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { GridPattern } from '@/components/ui/grid-pattern' import { AnimatedGridPattern } from '@/components/ui/animated-grid-pattern'
const ANNOTATION_COLORS: Record<string, string> = { const ANNOTATION_COLORS: Record<string, string> = {
deploy: '#3b82f6', deploy: '#3b82f6',
@@ -339,14 +339,7 @@ export default function Chart({
return ( return (
<div ref={chartContainerRef} className="relative"> <div ref={chartContainerRef} className="relative">
<Card className="w-full overflow-hidden rounded-2xl relative"> <Card className="w-full overflow-hidden rounded-2xl">
<GridPattern
width={30}
height={30}
x={-1}
y={-1}
className="fill-neutral-400/5 stroke-neutral-400/10 dark:fill-neutral-500/5 dark:stroke-neutral-500/10 [mask-image:linear-gradient(to_bottom,transparent,white_30%,white_70%,transparent)]"
/>
<CardHeader className="p-0 mb-0"> <CardHeader className="p-0 mb-0">
{/* Metrics Grid - 21st.dev style */} {/* Metrics Grid - 21st.dev style */}
<div className="grid grid-cols-2 md:grid-cols-4 grow w-full"> <div className="grid grid-cols-2 md:grid-cols-4 grow w-full">
@@ -392,7 +385,18 @@ export default function Chart({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-2.5 py-4"> <CardContent className="px-2.5 py-4 relative overflow-hidden">
<AnimatedGridPattern
numSquares={30}
maxOpacity={0.1}
duration={3}
repeatDelay={1}
width={30}
height={30}
x={-1}
y={-1}
className="fill-neutral-400/10 stroke-neutral-400/15 dark:fill-neutral-500/10 dark:stroke-neutral-500/15 [mask-image:linear-gradient(to_bottom,transparent,white_20%,white_80%,transparent)]"
/>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-4 px-2"> <div className="flex items-center justify-between gap-3 mb-4 px-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -0,0 +1,148 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
interface AnimatedGridPatternProps {
width?: number;
height?: number;
x?: number;
y?: number;
strokeDasharray?: any;
numSquares?: number;
className?: string;
maxOpacity?: number;
duration?: number;
repeatDelay?: number;
}
export function AnimatedGridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = 0,
numSquares = 50,
className,
maxOpacity = 0.5,
duration = 4,
repeatDelay = 0.5,
...props
}: AnimatedGridPatternProps) {
const id = useId();
const containerRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [squares, setSquares] = useState(() => generateSquares(numSquares));
function getPos() {
return [
Math.floor((Math.random() * dimensions.width) / width),
Math.floor((Math.random() * dimensions.height) / height),
];
}
// Adjust the generateSquares function to return objects with an id, x, and y
function generateSquares(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i,
pos: getPos(),
}));
}
// Function to update a single square's position
const updateSquarePosition = (id: number) => {
setSquares((currentSquares) =>
currentSquares.map((sq) =>
sq.id === id
? {
...sq,
pos: getPos(),
}
: sq,
),
);
};
// Update squares to animate in
useEffect(() => {
if (dimensions.width && dimensions.height) {
setSquares(generateSquares(numSquares));
}
}, [dimensions, numSquares]);
// Resize observer to update container dimensions
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
};
}, [containerRef]);
return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
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 }, index) => (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: maxOpacity }}
transition={{
duration,
repeat: 1,
delay: index * 0.1,
repeatType: "reverse",
}}
onAnimationComplete={() => updateSquarePosition(id)}
key={`${x}-${y}-${index}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
fill="currentColor"
strokeWidth="0"
/>
))}
</svg>
</svg>
);
}

View File

@@ -1,72 +0,0 @@
import { useId } from "react";
import { cn } from "@/lib/utils";
interface GridPatternProps {
width?: number;
height?: number;
x?: number;
y?: number;
squares?: Array<[x: number, y: number]>;
strokeDasharray?: string;
className?: string;
[key: string]: unknown;
}
function GridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = "0",
squares,
className,
...props
}: GridPatternProps) {
const id = useId();
return (
<svg
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
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%" strokeWidth={0} fill={`url(#${id})`} />
{squares && (
<svg x={x} y={y} className="overflow-visible">
{squares.map(([x, y]) => (
<rect
strokeWidth="0"
key={`${x}-${y}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
/>
))}
</svg>
)}
</svg>
);
}
export { GridPattern };