August 2026
Expandable Calendar Card with Spring-Animated Attendees
An expandable event notification card featuring continuous layout morphing via Motion, interactive attendee avatar fan-out with spring physics, and dynamic timezone indicator callouts.
calendar-widget.tsx
"use client";
import React, { useState, createContext, useContext } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { Clock01Icon, Globe02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import Image from "next/image";
export interface Guest {
id: number | string;
name: string;
image: string;
timezone?: string;
}
export interface CalendarWidgetProps {
title: string;
timeLabel: string;
timeRange: string;
guests: Guest[];
layoutId?: string;
className?: string;
}
export const CalendarWidget = ({
title,
timeLabel,
timeRange,
guests,
layoutId = "container",
className,
}: CalendarWidgetProps) => {
const [isOpen, setIsOpen] = useState(false);
if (!title || !timeLabel || !timeRange) {
console.warn(
"CalendarWidget: title, timeLabel, and timeRange are required props",
);
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsOpen(!isOpen);
}
};
return (
<>
<motion.div
layout
layoutId={layoutId}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-expanded={isOpen}
aria-label={`${title} event, ${timeLabel}, ${timeRange}. Click to ${isOpen ? "collapse" : "expand"} details.`}
className={cn(
"flex cursor-pointer flex-col justify-between rounded-2xl border border-neutral-200 bg-neutral-100 transition-all dark:border-neutral-900 dark:bg-black",
isOpen ? "h-56 w-110 py-2" : "h-30 w-60 py-2",
className,
)}
>
<div className="flex justify-between px-4 py-2">
<div className="flex flex-1 flex-col gap-4">
<motion.span
layoutId={`${layoutId}-time`}
className="flex w-20 items-center justify-center rounded-2xl bg-red-100/50 px-2 py-1 text-xs font-semibold text-red-500 dark:bg-cyan-500/10 dark:text-cyan-500"
>
{timeLabel}
</motion.span>
<div className="flex flex-col">
<motion.span
layoutId={`${layoutId}-title`}
className="font-semibold text-neutral-600 dark:text-neutral-200"
>
{title}
</motion.span>
<motion.span
layoutId={`${layoutId}-timeNum`}
className="text-xs font-semibold whitespace-nowrap text-neutral-500 dark:text-neutral-400"
>
{timeRange}
</motion.span>
</div>
</div>
<div
className={cn(
"flex items-start justify-end transition-opacity",
isOpen ? "opacity-100" : "pointer-events-none opacity-0",
)}
aria-hidden={!isOpen}
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg border-t border-red-300 bg-red-500 dark:border-cyan-100 dark:bg-cyan-600">
<HugeiconsIcon icon={Clock01Icon} />
</div>
</div>
</div>
{isOpen && guests.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="border-t-2 border-neutral-200 py-2 dark:border-neutral-800"
>
<HoverProvider>
<div className="flex justify-between">
<div className="flex flex-col gap-4">
<span className="flex items-center gap-2 px-4 font-semibold text-neutral-600 dark:text-neutral-300">
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 3,
repeat: Infinity,
ease: "linear",
}}
className="inline-flex"
aria-hidden="true"
>
<HugeiconsIcon icon={Globe02Icon} />
</motion.div>{" "}
Guests {guests.length}
</span>
<div className="flex px-4">
<Avatar items={guests} />
</div>
</div>
<div className="flex w-30 flex-col items-center justify-end px-3">
<div className="relative mb-3 flex h-4 justify-center">
{guests.map((guest, index) => (
<LineText
key={guest.id}
index={index}
text={
guest.timezone
? `${guest.name} ${guest.timezone}`
: guest.name
}
/>
))}
</div>
<div className="flex gap-2">
{guests.map((guest, index) => (
<Line key={guest.id} index={index} />
))}
</div>
</div>
</div>
</HoverProvider>
</motion.div>
)}
</motion.div>
</>
);
};
const HoverContext = createContext<{
hoveredIndex: number | null;
setHoveredIndex: (i: number | null) => void;
} | null>(null);
export const HoverProvider = ({ children }: { children: React.ReactNode }) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
return (
<HoverContext.Provider value={{ hoveredIndex, setHoveredIndex }}>
{children}
</HoverContext.Provider>
);
};
const useHover = () => {
const ctx = useContext(HoverContext);
if (!ctx) throw new Error("useHover must be used inside HoverProvider");
return ctx;
};
const Avatar = ({ items }: { items: Guest[] }) => {
const [hovered, setHovered] = useState(false);
const { setHoveredIndex } = useHover();
const [imageErrors, setImageErrors] = useState<Set<number | string>>(
new Set(),
);
const handleImageError = (id: number | string) => {
setImageErrors((prev) => new Set(prev).add(id));
};
if (items.length === 0) {
return null;
}
return (
<div
className="flex items-center"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => {
setHovered(false);
setHoveredIndex(null);
}}
role="group"
aria-label="Event guests"
>
{items.map((item, index) => (
<motion.div
key={item.id}
className="-mr-4"
onMouseEnter={() => setHoveredIndex(index)}
animate={{
x: hovered ? index * 20 : 0,
}}
transition={{
type: "spring",
stiffness: 200,
damping: 20,
}}
>
{imageErrors.has(item.id) ? (
<div
className="flex h-9 w-9 items-center justify-center rounded-full border-2 border-neutral-300 bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400"
aria-label={`${item.name} avatar`}
>
{item.name.charAt(0).toUpperCase()}
</div>
) : (
<Image
src={item.image}
alt={`${item.name} avatar`}
width={36}
height={36}
className="h-9 w-9 rounded-full border-2 border-neutral-300 object-cover object-top"
onError={() => handleImageError(item.id)}
loading="lazy"
unoptimized
/>
)}
</motion.div>
))}
</div>
);
};
const Line = ({ index }: { index: number }) => {
const { hoveredIndex } = useHover();
const hovered = hoveredIndex === index;
return (
<motion.div
animate={{
scaleY: hovered ? 1.1 : 1,
background: hovered
? "var(--hover-gradient)"
: "var(--default-gradient)",
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
duration: 0.3,
}}
className="h-8 w-2 rounded-2xl [--default-gradient:linear-gradient(to_bottom,#a3a3a3,#737373)] [--hover-gradient:linear-gradient(to_bottom,#f87171,#ef4444,#dc2626)] dark:[--default-gradient:linear-gradient(to_bottom,#525252,#262626)] dark:[--hover-gradient:linear-gradient(to_bottom,#22d3ee,#06b6d4,#0891b2)]"
style={{
background: hovered
? "var(--hover-gradient)"
: "var(--default-gradient)",
}}
aria-hidden="true"
/>
);
};
const LineText = ({ index, text }: { index: number; text: string }) => {
const { hoveredIndex } = useHover();
const active = hoveredIndex === index;
return (
<motion.span
className="absolute text-xs font-bold whitespace-nowrap text-red-500/90 dark:text-cyan-400"
initial={{ opacity: 0 }}
animate={{ opacity: active ? 1 : 0 }}
transition={{ duration: 0.15 }}
aria-hidden="true"
>
{text}
</motion.span>
);
};More Soon