diff --git a/public/images/features/live-occupancy-dark.webp b/public/images/features/live-occupancy-dark.webp
deleted file mode 100644
index 60581e3..0000000
Binary files a/public/images/features/live-occupancy-dark.webp and /dev/null differ
diff --git a/public/images/features/live-occupancy.webp b/public/images/features/live-occupancy.webp
deleted file mode 100644
index 8734931..0000000
Binary files a/public/images/features/live-occupancy.webp and /dev/null differ
diff --git a/src/components/BentoGrid.tsx b/src/components/BentoGrid.tsx
index 05daf74..443db28 100644
--- a/src/components/BentoGrid.tsx
+++ b/src/components/BentoGrid.tsx
@@ -1,11 +1,13 @@
+import { type ReactNode } from "react";
import { motion } from "framer-motion";
import styles from "./BentoGrid.module.css";
export interface BentoItem {
title: string;
description: string;
- image: string;
+ image?: string;
imageDark?: string;
+ component?: ReactNode;
size: "small" | "medium" | "large" | "wide" | "full";
theme?: "gold" | "night" | "ios26" | "spectrum";
}
@@ -61,8 +63,14 @@ export default function BentoGrid({ items }: BentoGridProps) {
{item.description}
-

- {item.imageDark &&

}
+ {item.component ? (
+ item.component
+ ) : item.image ? (
+ <>
+

+ {item.imageDark &&

}
+ >
+ ) : null}
);
diff --git a/src/components/OccupancyCard.module.css b/src/components/OccupancyCard.module.css
new file mode 100644
index 0000000..11eabc7
--- /dev/null
+++ b/src/components/OccupancyCard.module.css
@@ -0,0 +1,92 @@
+.wrapper {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ padding: var(--space-md);
+ padding-top: 0;
+ overflow: hidden;
+}
+
+.card {
+ background: #faf9f7;
+ border-radius: var(--radius-xl);
+ padding: var(--space-xl);
+ padding-bottom: var(--space-2xl);
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.08);
+ transform: translateY(20%);
+}
+
+@media (prefers-color-scheme: dark) {
+ .card {
+ background: #2a2a2a;
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
+ }
+}
+
+.gaugeContainer {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.gauge {
+ display: block;
+}
+
+.track {
+ color: #d4ccc4;
+}
+
+.fill {
+ color: #a89080;
+}
+
+@media (prefers-color-scheme: dark) {
+ .track {
+ color: #4a4540;
+ }
+
+ .fill {
+ color: #d4bfa8;
+ }
+}
+
+.valueContainer {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+
+.percentage {
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1;
+ color: #2a2a2a;
+ font-variant-numeric: tabular-nums;
+}
+
+@media (prefers-color-scheme: dark) {
+ .percentage {
+ color: #f5f5f5;
+ }
+}
+
+.label {
+ font-size: 1rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ color: #b0a090;
+ margin-top: 0.25em;
+}
+
+@media (prefers-color-scheme: dark) {
+ .label {
+ color: #908880;
+ }
+}
diff --git a/src/components/OccupancyCard.tsx b/src/components/OccupancyCard.tsx
new file mode 100644
index 0000000..5d7c177
--- /dev/null
+++ b/src/components/OccupancyCard.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useState } from "react";
+import { motion, useSpring, useTransform, useMotionValueEvent } from "framer-motion";
+import styles from "./OccupancyCard.module.css";
+
+interface OccupancyCardProps {
+ className?: string;
+}
+
+const occupancyStates = [
+ { value: 23, label: "QUIET" },
+ { value: 45, label: "CALM" },
+ { value: 67, label: "BUSY" },
+ { value: 82, label: "CROWDED" },
+ { value: 34, label: "CALM" },
+ { value: 91, label: "FULL" },
+ { value: 56, label: "BUSY" },
+ { value: 12, label: "QUIET" },
+];
+
+export default function OccupancyCard({ className }: OccupancyCardProps) {
+ const [stateIndex, setStateIndex] = useState(0);
+ const [displayNumber, setDisplayNumber] = useState(occupancyStates[0].value);
+ const currentState = occupancyStates[stateIndex];
+
+ // Spring animation for smooth value transitions
+ const springValue = useSpring(currentState.value, {
+ stiffness: 50,
+ damping: 20,
+ });
+
+ // Update display number when spring value changes
+ useMotionValueEvent(springValue, "change", (latest) => {
+ setDisplayNumber(Math.round(latest));
+ });
+
+ // Cycle through states
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setStateIndex((prev) => (prev + 1) % occupancyStates.length);
+ }, 3000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ // Update spring when state changes
+ useEffect(() => {
+ springValue.set(currentState.value);
+ }, [currentState.value, springValue]);
+
+ // SVG circle calculations
+ const size = 180;
+ const strokeWidth = 18;
+ const radius = (size - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * radius;
+
+ // Transform spring value to stroke offset
+ const strokeDashoffset = useTransform(springValue, (v) => circumference - (v / 100) * circumference);
+
+ return (
+
+
+
+
+
+ {displayNumber}%
+
+ {currentState.label}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/home/sections/Features.tsx b/src/pages/home/sections/Features.tsx
index 4c07446..6ddc545 100644
--- a/src/pages/home/sections/Features.tsx
+++ b/src/pages/home/sections/Features.tsx
@@ -1,13 +1,13 @@
import { motion } from "framer-motion";
import BentoGrid, { type BentoItem } from "../../../components/BentoGrid";
+import OccupancyCard from "../../../components/OccupancyCard";
import styles from "./Features.module.css";
const featureItems: BentoItem[] = [
{
title: "Live Occupancy",
description: "Real-time occupancy, no reload required",
- image: "/images/features/live-occupancy.webp",
- imageDark: "/images/features/live-occupancy-dark.webp",
+ component: ,
size: "medium",
theme: "gold",
},