diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 00000000..ada784ee --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,27 @@ +name: React Doctor + +on: + # Scans the PR's changed files and posts a sticky summary comment listing only the new issues introduced relative to the merge base of the target branch. + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +# Cancels any in-flight scan for the same PR the moment a new commit arrives, so reviewers only ever see the latest run. +concurrency: + group: react-doctor-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: millionco/react-doctor@v2 diff --git a/.gitignore b/.gitignore index aa3aae8d..d4309f91 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ public/llms.txt public/llms-full.txt _internal/ .superset/ + +# react-doctor local report +react-doctor-report.json diff --git a/CLAUDE.md b/CLAUDE.md index 04be73b8..22ab7fb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,44 +21,43 @@ Animata is a free, open-source library of animated React components built with N ## Changelog rule — ALWAYS update this -The changelog lives at `content/docs/changelog/` — one MDX file per month plus an index. +Release notes use **four tiers**. Full process, examples, and checklist: [contributing changelog](/docs/contributing/changelog). -### Structure +| Tier | Where | Length | +| --- | --- | --- | +| 1 — Site overview | `content/docs/changelog/index.mdx` | One line per month | +| 2 — Monthly release | `content/docs/changelog/YYYY-MM.mdx` | Paragraph + component cards | +| 3 — Category index | `content/docs/{category}/index.mdx` | Table: date · component · change | +| 4 — Component doc | `content/docs/{category}/{name}.mdx` → `## Changelog` | Dated bullets, most detail | ```text content/docs/changelog/ - index.mdx # overview page — shows 2 most recent months + table linking all others - 2026-04.mdx # April 2026 (newest first) - 2026-03.mdx - ... + index.mdx # tier 1 — recent months + table of all months + YYYY-MM.mdx # tier 2 — one file per month (newest first) +content/docs/{category}/ + index.mdx # tier 3 — Recent changes table + {name}.mdx # tier 4 — ## Changelog at bottom ``` ### When adding a new month -1. Create `content/docs/changelog/YYYY-MM.mdx` with this frontmatter: - ```yaml - --- - title: Month YYYY - description: One-line summary of the main themes. - date: YYYY-MM-DD - --- - ``` -2. Write the content as prose, not bullet lists. Each new component gets a short paragraph — what it is, when you'd use it, anything notable about the implementation. -3. Add the new month to `config/docs.ts` under the Changelog items array (newest first). -4. Update `content/docs/changelog/index.mdx`: promote the new month to "Recent releases" and move the oldest of the current two into the table. +1. Create `content/docs/changelog/YYYY-MM.mdx` with frontmatter (`title`, `description`, `date`). +2. Write tier-2 prose and `` blocks — see [contributing changelog](/docs/contributing/changelog). +3. Add the month to `config/docs.ts` under Changelog (newest first). +4. Update tier 1 in `changelog/index.mdx`: promote to **Recent releases**, demote oldest to **All releases**. ### When updating an existing month -Add to the relevant `YYYY-MM.mdx` file. No need to touch the index unless the summary line there is now misleading. +Add to the relevant `YYYY-MM.mdx`. Update tier 3 (category index table) and tier 4 (component `## Changelog`). Touch tier 1 only if the month summary line is now misleading. ### What warrants a changelog entry -| Action | Update changelog? | -|---|---| -| New component | Yes — new section in the current month file | -| Landing/docs UI update | Yes — brief paragraph | -| Major dependency upgrade | Yes — explain what changed and why it matters | -| User-visible bug fix | Yes — one sentence is fine | +| Action | Update? | +| --- | --- | +| New component | All four tiers | +| User-visible update or fix | Tiers 2–4; tier 1 if it shapes the month | +| Landing/docs UI (site-wide) | Tiers 1–2 | +| Major dependency upgrade | Tiers 1–2 | | Dependency patch bumps | No | | CI / GitHub Actions only | No | | Typo fix in docs | No | @@ -66,9 +65,8 @@ Add to the relevant `YYYY-MM.mdx` file. No need to touch the index unless the su ### Writing style - Past tense. "Added X" not "We're excited to announce X." -- No emojis, no prefix symbols (no ✨ 🛠 🐛). -- Each component gets a description of what it does and when you'd use it — not just its name. -- If a fix has a root cause worth knowing, say so briefly. +- No emojis, no prefix symbols. +- Detail increases down the tiers — one line at the top, bullets at the component doc. ## File map (quick reference) @@ -97,6 +95,7 @@ content/docs/ contributing/ category-glyphs.mdx # Category tile SVG guide (published) category-glyphs-spec.md # Full spec — keep in sync; not under animata/ + changelog.mdx # Four-tier release notes process config/ docs.ts # Sidebar nav config — register new component categories here styles/globals.css # Tailwind v4 theme tokens diff --git a/animata/background/interactive-grid.tsx b/animata/background/interactive-grid.tsx index 60a7bad1..2e1348d4 100644 --- a/animata/background/interactive-grid.tsx +++ b/animata/background/interactive-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; @@ -8,7 +8,7 @@ function useGridLayout() { const containerRef = useRef(null); const [layout, setLayout] = useState({ vertical: 0, horizontal: 0 }); - useEffect(() => { + useLayoutEffect(() => { const updateLayout = () => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) { @@ -72,7 +72,7 @@ function Grid() { const [active, setActive] = useState(0); const timerRef = useRef(undefined); - const onMouseEnter = useCallback(() => { + const onActivate = useCallback(() => { if (timerRef.current) { clearInterval(timerRef.current); } @@ -108,13 +108,15 @@ function Grid() { const shouldHighlight = active - x === x || active - y === y; return ( -
-
+ ); }); - }, [squares, horizontal, active, onMouseEnter]); + }, [squares, horizontal, active, onActivate]); return (
96 * 4, // 96 * 4 is the height of the grid })} diff --git a/animata/background/shooting-stars.tsx b/animata/background/shooting-stars.tsx index 5b0ceb53..86958db5 100644 --- a/animata/background/shooting-stars.tsx +++ b/animata/background/shooting-stars.tsx @@ -120,16 +120,21 @@ export default function ShootingStars({ setStars(Array.from({ length: starCount }, () => makeStar(nextId.current++, w, h, true))); }; - seed(); + const seedFrame = requestAnimationFrame(() => seed()); const ro = new ResizeObserver(() => { const r = el.getBoundingClientRect(); dims.current = { w: r.width, h: r.height }; // If we mounted at 0×0 (hidden tab, collapsed/lazy panel), seed once the // container actually has a size — otherwise the sky stays empty. - if (!seeded && r.width && r.height) seed(); + if (!seeded && r.width && r.height) { + requestAnimationFrame(() => seed()); + } }); ro.observe(el); - return () => ro.disconnect(); + return () => { + cancelAnimationFrame(seedFrame); + ro.disconnect(); + }; }, []); const recycle = useCallback((id: number) => { diff --git a/animata/button/ai-button.tsx b/animata/button/ai-button.tsx index f36de886..ce3afffb 100644 --- a/animata/button/ai-button.tsx +++ b/animata/button/ai-button.tsx @@ -125,6 +125,7 @@ export default function AiButton() { return ( ); diff --git a/animata/button/algolia-white-button.tsx b/animata/button/algolia-white-button.tsx index bbc07d09..b6c330fc 100644 --- a/animata/button/algolia-white-button.tsx +++ b/animata/button/algolia-white-button.tsx @@ -2,7 +2,10 @@ import "./algolia-white-button.css"; export default function AlgoliaWhiteButton() { return ( - ); diff --git a/animata/button/arrow-button.tsx b/animata/button/arrow-button.tsx index c1d5f256..d5c1bc8a 100644 --- a/animata/button/arrow-button.tsx +++ b/animata/button/arrow-button.tsx @@ -24,6 +24,7 @@ export default function ArrowButton({ }: ArrowButtonProps) { return ( diff --git a/animata/button/external-link-button.tsx b/animata/button/external-link-button.tsx index 8f4fa005..69ea6461 100644 --- a/animata/button/external-link-button.tsx +++ b/animata/button/external-link-button.tsx @@ -9,7 +9,10 @@ interface ButtonTitleProps { export default function ExternalLinkButton({ text = "Open Link" }: ButtonTitleProps) { return ( - diff --git a/animata/card/blur-stack-card.tsx b/animata/card/blur-stack-card.tsx index 28f2fcab..99215d0b 100644 --- a/animata/card/blur-stack-card.tsx +++ b/animata/card/blur-stack-card.tsx @@ -1,7 +1,7 @@ "use client"; + import { motion } from "motion/react"; import { useState } from "react"; - import { cn } from "@/lib/utils"; interface RowProps { @@ -54,9 +54,11 @@ export default function BlueStackCards() {
{cards.map((card, index) => ( -
-
+ ))}
diff --git a/animata/card/card-spread.css b/animata/card/card-spread.css index 5335bbf4..d374079e 100644 --- a/animata/card/card-spread.css +++ b/animata/card/card-spread.css @@ -1,31 +1,187 @@ +@reference "../../styles/globals.css"; + @layer components { - .card-spread-stage { - /* Quick throw, soft settle — expand on click only */ + .card-spread { --ease-throw: cubic-bezier(0.22, 1.18, 0.36, 1); + --ease-gather: cubic-bezier(0.33, 1, 0.68, 1); + } + + .card-spread-stage { + /* + * overflow-x: auto forces overflow-y: auto — pad vertically so spread + * tilt and hover peek are not clipped. + */ + @apply w-full px-4 py-8; + } + + /* Deck: center the wide grid, clip sides — stack sits at grid center (col 1.5). */ + .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-stage { + @apply flex items-end justify-center overflow-x-hidden; + } + + /* Spread: horizontal scroll when needed; center the row when it fits. */ + .card-spread:has(.card-spread-toggle:checked) .card-spread-stage { + @apply overflow-x-auto overscroll-x-contain; + } + + @media (min-width: 64rem) { + .card-spread:has(.card-spread-toggle:checked) .card-spread-stage { + @apply flex items-end justify-center; + } + } + + .card-spread-container { + --card-gap: 0.75rem; + /* 4× w-48 + 3× gap-3 — never shrink or deck offsets miss grid tracks */ + @apply grid w-fit min-w-[calc(4*12rem+3*0.75rem)] shrink-0 grid-cols-4 gap-3; + } + + .card-spread-toggle { + @apply sr-only; } - .card-spread-motion--expand { + .card-spread-open { + @apply relative inline-flex cursor-pointer items-center rounded-full border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors select-none; + } + + .card-spread-open:hover { + @apply bg-accent/10; + } + + .card-spread-open:has(.card-spread-toggle:focus-visible) { + @apply outline-2 outline-[#ffcc00] outline-offset-2; + } + + .card-spread-open__text--collapse { + @apply hidden; + } + + .card-spread:has(.card-spread-toggle:checked) .card-spread-open__text--expand { + @apply hidden; + } + + .card-spread:has(.card-spread-toggle:checked) .card-spread-open__text--collapse { + @apply inline; + } + + .card-spread-item { + @apply origin-bottom; + } + + .card-spread-item:nth-child(1) { + @apply z-10; + } + + .card-spread-item:nth-child(2) { + @apply z-20; + } + + .card-spread-item:nth-child(3) { + @apply z-30; + } + + .card-spread-item:nth-child(4) { + @apply z-40; + } + + .card-spread-item__internal { + @apply origin-bottom motion-reduce:transition-none; + + transition: transform 520ms var(--ease-gather); + } + + .card-spread:has(.card-spread-toggle:checked) .card-spread-item__internal { transition: transform 480ms var(--ease-throw); } - .card-spread-motion--collapse { - transition: transform 520ms cubic-bezier(0.33, 1, 0.68, 1); + /* + * Deck — 1×4 grid, pull each card toward col 1.5 via transform. + * Same explicit calc() on all breakpoints so spread animates on mobile too. + */ + .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-item:nth-child(1) + .card-spread-item__internal { + transform: translate3d(calc(150% + 1.125rem), 0, 0); + } + + .card-spread:not(:has(.card-spread-toggle:checked)) + .card-spread-item:nth-child(2) + .card-spread-item__internal { + transform: translate3d(calc(50% + 0.375rem), 0, 0); + } + + .card-spread:not(:has(.card-spread-toggle:checked)) + .card-spread-item:nth-child(3) + .card-spread-item__internal { + transform: translate3d(calc(-50% - 0.375rem), 0, 0); + } + + .card-spread:not(:has(.card-spread-toggle:checked)) + .card-spread-item:nth-child(4) + .card-spread-item__internal { + transform: translate3d(calc(-150% - 1.125rem), 0, 0); + } + + /* Spread — layout slots, light rotation */ + .card-spread:has(.card-spread-toggle:checked) .card-spread-item:nth-child(1) + .card-spread-item__internal { + transform: translate3d(0, 0, 0) rotate(-2deg); + } + + .card-spread:has(.card-spread-toggle:checked) + .card-spread-item:nth-child(2) + .card-spread-item__internal { + transform: translate3d(0, 0.5rem, 0) rotate(3deg); + } + + .card-spread:has(.card-spread-toggle:checked) + .card-spread-item:nth-child(3) + .card-spread-item__internal { + transform: translate3d(0.25rem, 0, 0) rotate(-2deg); + } + + .card-spread:has(.card-spread-toggle:checked) + .card-spread-item:nth-child(4) + .card-spread-item__internal { + transform: translate3d(0, 0, 0) rotate(2deg); + } + + /* Hover peek — deck only */ + .card-spread-item__hover { + @apply origin-bottom transition-transform duration-300 ease-out motion-reduce:transition-none; + } + + .card-spread:not(:has(.card-spread-toggle:checked)) + .card-spread-stage:hover + .card-spread-item:nth-child(1) + .card-spread-item__hover { + @apply -rotate-1; + } + + .card-spread:not(:has(.card-spread-toggle:checked)) + .card-spread-stage:hover + .card-spread-item:nth-child(2) + .card-spread-item__hover { + @apply -rotate-2; } - .card-spread-stage--expand { - transition: width 480ms var(--ease-throw); + .card-spread:not(:has(.card-spread-toggle:checked)) + .card-spread-stage:hover + .card-spread-item:nth-child(3) + .card-spread-item__hover { + @apply rotate-1; } - .card-spread-stage--collapse { - transition: width 520ms cubic-bezier(0.33, 1, 0.68, 1); + .card-spread:not(:has(.card-spread-toggle:checked)) + .card-spread-stage:hover + .card-spread-item:nth-child(4) + .card-spread-item__hover { + @apply rotate-2; } @media (prefers-reduced-motion: reduce) { - .card-spread-motion--expand, - .card-spread-motion--collapse, - .card-spread-stage--expand, - .card-spread-stage--collapse { - transition-duration: 0.01ms !important; + .card-spread-item__internal, + .card-spread-item__hover { + @apply !duration-[0.01ms]; } } } diff --git a/animata/card/card-spread.tsx b/animata/card/card-spread.tsx index 216571bc..9e32c811 100644 --- a/animata/card/card-spread.tsx +++ b/animata/card/card-spread.tsx @@ -1,21 +1,8 @@ -"use client"; - -import { useState } from "react"; - import Notes, { NotesCard } from "@/animata/widget/notes"; import ShoppingList from "@/animata/widget/shopping-list"; -import { cn } from "@/lib/utils"; import "./card-spread.css"; -/** w-48 + gap-3 — horizontal step between spread slots */ -const CARD_STEP = "12.75rem"; - -/** 4× w-48 + 3× gap-3 */ -const SPREAD_WIDTH = "calc(4 * 12rem + 3 * 0.75rem)"; - -const DECK_TRANSFORM = "translateX(0) rotate(0deg)"; - function Reminders() { return ( -
setExpanded((open) => !open)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setExpanded((open) => !open); - } - }} - className={cn( - "card-spread-stage group/stack relative h-64 outline-none focus-visible:ring-2 focus-visible:ring-[#ffcc00] focus-visible:ring-offset-2 focus-visible:ring-offset-background", - isExpanded - ? "card-spread-stage--expand cursor-default" - : "card-spread-stage--collapse w-48 cursor-pointer", - )} - style={isExpanded ? { width: SPREAD_WIDTH } : undefined} - > - {cards.map((item, index) => { - const Card = item.component; - return ( -
-
- +
+ + +
+
+ {cards.map((item) => { + const Card = item.component; + + return ( +
+
+
+ +
+
-
- ); - })} + ); + })} +
); diff --git a/animata/card/card-stack-profile.tsx b/animata/card/card-stack-profile.tsx new file mode 100644 index 00000000..3edc21d9 --- /dev/null +++ b/animata/card/card-stack-profile.tsx @@ -0,0 +1,246 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { Heart, MessageCircle } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; + +import CardStack, { + type CardStackItem, + type CardStackLayerMotion, +} from "@/animata/card/card-stack"; +import { CardStackMaskDefs } from "@/components/shapes/card-stack-mask-defs"; +import { cn } from "@/lib/utils"; + +export const CARD_STACK_MASK_IDS = [ + "cardstack_mask_ellipse-1", + "cardstack_mask_flower-14", + "cardstack_mask_flower-1", + "cardstack_mask_misc-5", +] as const; + +export type CardStackProfileMaskId = (typeof CARD_STACK_MASK_IDS)[number]; + +export type CardStackProfileMediaAspect = "fill" | "square" | "4/5" | "3/4" | "16/10"; + +export interface CardStackProfileItem extends CardStackItem { + image: string; + title: string; + tagline: string; + counts?: { + like: number; + comment: number; + }; + maskId: CardStackProfileMaskId; +} + +const CARD_STACK_MASK_STYLE = { + maskSize: "cover", + maskPosition: "center", + maskRepeat: "no-repeat", +} as const; + +const MEDIA_ASPECT_CLASS: Record, string> = { + square: "aspect-square", + "4/5": "aspect-[4/5]", + "3/4": "aspect-[3/4]", + "16/10": "aspect-[16/10]", +}; + +export function CardStackProfileMasks({ className }: { className?: string }) { + return ; +} + +export function CardStackProfileLiveRegion({ + className, + item, +}: { + className?: string; + item: CardStackProfileItem | undefined; +}) { + return ( +

+ {item ? `Showing ${item.title}, ${item.tagline}` : "No cards available"} +

+ ); +} + +export function CardStackProfileHeader({ className, ...props }: ComponentProps<"header">) { + return
; +} + +export function CardStackProfileAvatar({ + src, + className, + ...props +}: ComponentProps<"div"> & { src: string }) { + return ( +
+ +
+ ); +} + +export function CardStackProfileMeta({ + title, + tagline, + className, + ...props +}: ComponentProps<"div"> & { title: string; tagline: string }) { + return ( +
+

+ {title} +

+

+ {tagline} +

+
+ ); +} + +export function CardStackProfileMedia({ + src, + alt, + maskId, + aspect = "square", + className, + style, + ...props +}: Omit, "src" | "alt"> & { + src: string; + alt: string; + maskId: CardStackProfileMaskId; + aspect?: CardStackProfileMediaAspect; +}) { + const maskStyle = { + ...CARD_STACK_MASK_STYLE, + maskImage: `url(#${maskId})`, + WebkitMaskImage: `url(#${maskId})`, + WebkitMaskSize: CARD_STACK_MASK_STYLE.maskSize, + WebkitMaskPosition: CARD_STACK_MASK_STYLE.maskPosition, + WebkitMaskRepeat: CARD_STACK_MASK_STYLE.maskRepeat, + ...style, + }; + + return ( +
+ {alt} +
+ ); +} + +export function CardStackProfileFooter({ className, ...props }: ComponentProps<"footer">) { + return ( +
+ ); +} + +export function CardStackProfileMetric({ + icon: Icon, + label, + value, + className, + ...props +}: ComponentProps<"span"> & { icon: LucideIcon; label: string; value: number | string }) { + return ( + + + {label}: + {value} + + ); +} + +interface CardStackProfileCardProps { + item: CardStackProfileItem; + index: number; + layer: CardStackLayerMotion; + className?: string; + footerTrailing?: ReactNode; + showMetrics?: boolean; +} + +/** Profile-style card for demos and Storybook — not part of the core stack API. */ +export function CardStackProfileCard({ + item, + index, + layer, + className, + footerTrailing, + showMetrics = true, +}: CardStackProfileCardProps) { + return ( + + + + + + + + + {showMetrics || footerTrailing ? ( + + {showMetrics ? ( + <> + + + + ) : null} + {footerTrailing} + + ) : null} + + ); +} diff --git a/animata/card/card-stack.stories.tsx b/animata/card/card-stack.stories.tsx index 6b6b7322..318d6ff0 100644 --- a/animata/card/card-stack.stories.tsx +++ b/animata/card/card-stack.stories.tsx @@ -1,8 +1,16 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { ArrowRight, Heart, MessageCircle } from "lucide-react"; -import CardStack, { CARD_STACK_MASK_IDS, type CardStackItem } from "@/animata/card/card-stack"; +import { ArrowRight } from "lucide-react"; -const demoCards: CardStackItem[] = [ +import CardStack, { useCardStack } from "@/animata/card/card-stack"; +import { + CARD_STACK_MASK_IDS, + CardStackProfileCard, + type CardStackProfileItem, + CardStackProfileLiveRegion, + CardStackProfileMasks, +} from "@/animata/card/card-stack-profile"; + +const demoCards: CardStackProfileItem[] = [ { id: "hari", image: @@ -59,6 +67,11 @@ const demoCards: CardStackItem[] = [ }, ]; +function StoryLiveRegion() { + const { activeItem } = useCardStack(); + return ; +} + const meta = { title: "Card/Card Stack", component: CardStack, @@ -84,52 +97,44 @@ export const Primary: Story = { render: ({ items, depth }) => (
- - +
+
- - - - - - {(item, index, layer) => ( - - - - - + - - - - - + + + {(item: CardStackProfileItem, index, layer) => ( + + + + ) : ( - - - )} - - - + ) + } + /> + )} + +
- +
), diff --git a/animata/card/card-stack.tsx b/animata/card/card-stack.tsx index 809bb2e7..99ef2bfb 100644 --- a/animata/card/card-stack.tsx +++ b/animata/card/card-stack.tsx @@ -1,7 +1,7 @@ "use client"; -import type { LucideIcon } from "lucide-react"; -import { AnimatePresence, type HTMLMotionProps, motion, type Transition } from "motion/react"; +import type { HTMLMotionProps, Transition } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { type ComponentProps, cloneElement, @@ -14,42 +14,21 @@ import { useMemo, useRef, useState, - useSyncExternalStore, } from "react"; - -import { CardStackMaskDefs } from "@/components/shapes/card-stack-mask-defs"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; import { cn } from "@/lib/utils"; -export const CARD_STACK_MASK_IDS = [ - "cardstack_mask_ellipse-1", - "cardstack_mask_flower-14", - "cardstack_mask_flower-1", - "cardstack_mask_misc-5", -] as const; - -export type CardStackMaskId = (typeof CARD_STACK_MASK_IDS)[number]; - -export type CardStackMediaAspect = "fill" | "square" | "4/5" | "3/4" | "16/10"; - export interface CardStackItem { id: string; - image: string; - title: string; - tagline: string; - counts?: { - like: number; - comment: number; - }; - maskId: CardStackMaskId; } export interface CardStackLayerMotion { className: string; - initial: HTMLMotionProps<"article">["initial"]; - animate: HTMLMotionProps<"article">["animate"]; - exit?: HTMLMotionProps<"article">["exit"]; + initial: HTMLMotionProps<"div">["initial"]; + animate: HTMLMotionProps<"div">["animate"]; + exit?: HTMLMotionProps<"div">["exit"]; transition: Transition; - style?: HTMLMotionProps<"article">["style"]; + style?: HTMLMotionProps<"div">["style"]; } const DEFAULT_STACK_DEPTH = 3; @@ -108,33 +87,6 @@ export function createCardStackThrowImpulse(): CardStackThrowImpulse { const CARD_STACK_STACK_ORIGIN = "50% 0%"; const CARD_STACK_EXIT_Y = "200%"; -const CARD_STACK_MASK_STYLE = { - maskSize: "cover", - maskPosition: "center", - maskRepeat: "no-repeat", -} as const; - -const MEDIA_ASPECT_CLASS: Record, string> = { - square: "aspect-square", - "4/5": "aspect-[4/5]", - "3/4": "aspect-[3/4]", - "16/10": "aspect-[16/10]", -}; - -function subscribeReducedMotion(callback: () => void) { - const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); - mq.addEventListener("change", callback); - return () => mq.removeEventListener("change", callback); -} - -function getReducedMotionSnapshot() { - return window.matchMedia("(prefers-reduced-motion: reduce)").matches; -} - -function usePrefersReducedMotion() { - return useSyncExternalStore(subscribeReducedMotion, getReducedMotionSnapshot, () => false); -} - export function getCardStackLayers( reducedMotion: boolean, depth = DEFAULT_STACK_DEPTH, @@ -195,7 +147,7 @@ function getCardStackInitial( stackIndex: number, depth: number, layer: CardStackLayerMotion, -): HTMLMotionProps<"article">["initial"] { +): HTMLMotionProps<"div">["initial"] { if (stackIndex !== 0 && stackIndex !== depth - 1) { return false; } @@ -208,7 +160,7 @@ function getCardStackExit( layer: CardStackLayerMotion, reducedMotion: boolean, throwImpulse: CardStackThrowImpulse | null, -): HTMLMotionProps<"article">["exit"] { +): HTMLMotionProps<"div">["exit"] { if (stackIndex !== 0) { return undefined; } @@ -236,10 +188,10 @@ function getCardStackExit( }; } -interface CardStackContextValue { - items: CardStackItem[]; - visibleItems: CardStackItem[]; - activeItem: CardStackItem | undefined; +interface CardStackContextValue { + items: T[]; + visibleItems: T[]; + activeItem: T | undefined; depth: number; isAnimating: boolean; pressActive: boolean; @@ -253,38 +205,39 @@ interface CardStackContextValue { const CardStackContext = createContext(null); -export function useCardStack() { +export function useCardStack() { const context = use(CardStackContext); if (!context) { throw new Error("CardStack primitives must be used within ."); } - return context; + return context as CardStackContextValue; } -interface CardStackRootProps { - items: CardStackItem[]; +interface CardStackRootProps { + items: T[]; depth?: number; autoplay?: boolean; autoplayInterval?: number; - onItemsChange?: (items: CardStackItem[]) => void; + onItemsChange?: (items: T[]) => void; children: ReactNode; } -function CardStackRoot({ +function CardStackRoot({ items, depth = DEFAULT_STACK_DEPTH, autoplay = false, autoplayInterval = DEFAULT_AUTOPLAY_INTERVAL, onItemsChange, children, -}: CardStackRootProps) { +}: CardStackRootProps) { const reducedMotion = usePrefersReducedMotion(); const stackDepth = Math.min(Math.max(1, depth), STACK_LAYER_PRESETS.length); const layers = useMemo( () => getCardStackLayers(reducedMotion, stackDepth), [reducedMotion, stackDepth], ); - const [itemList, setItemList] = useState(items); + const [itemList, setItemList] = useState([]); + const prevItemsRef = useRef(null); const [isAnimating, setIsAnimating] = useState(false); const [pressActive, setPressActive] = useState(false); const [throwImpulse, setThrowImpulse] = useState(null); @@ -292,6 +245,10 @@ function CardStackRoot({ const stepTimerRef = useRef | null>(null); const autoplayTimerRef = useRef | null>(null); const advanceRef = useRef<() => void>(() => {}); + const onItemsChangeRef = useRef(onItemsChange); + onItemsChangeRef.current = onItemsChange; + const skipItemsChangeNotifyRef = useRef(false); + const hasUserRotatedRef = useRef(false); const visibleItems = itemList.slice(0, stackDepth); const activeItem = visibleItems[0]; @@ -310,23 +267,34 @@ function CardStackRoot({ } }, []); - useEffect(() => { + if (items !== prevItemsRef.current) { clearStepTimer(); clearAutoplayTimer(); isAnimatingRef.current = false; setIsAnimating(false); + skipItemsChangeNotifyRef.current = true; + prevItemsRef.current = items; setItemList(items); - }, [items, clearStepTimer, clearAutoplayTimer]); + } + + useEffect(() => { + if (skipItemsChangeNotifyRef.current) { + skipItemsChangeNotifyRef.current = false; + return; + } + if (!hasUserRotatedRef.current) return; + onItemsChangeRef.current?.(itemList as T[]); + }, [itemList]); const rotateOne = useCallback(() => { + hasUserRotatedRef.current = true; setItemList((current) => { if (current.length <= 1) return current; const next = [...current]; next.push(next.shift()!); - onItemsChange?.(next); return next; }); - }, [onItemsChange]); + }, []); const finishStep = useCallback(() => { clearStepTimer(); @@ -442,84 +410,132 @@ function CardStackRoot({ return {children}; } -function CardStackFrame({ - "aria-label": ariaLabel = "Interactive card stack", - className, - children, - ...props -}: ComponentProps<"section">) { - return ( -
- {children} -
- ); -} - -function CardStackPanel({ className, children, ...props }: ComponentProps<"div">) { - return ( -
- {children} -
- ); -} - -function CardStackLiveRegion({ className }: { className?: string }) { - const { activeItem } = useCardStack(); - - return ( -

- {activeItem ? `Showing ${activeItem.title}, ${activeItem.tagline}` : "No cards available"} -

- ); +export interface CardStackTriggerProps { + /** When true, renders an absolute inset-0 overlay for click-anywhere advance. When false (default), renders a real ` @@ -528,24 +544,18 @@ function CardStackTrigger({ function CardStackViewport({ className, children, ...props }: ComponentProps<"div">) { return ( -
+
{children}
); } -interface CardStackListProps { - children: (item: CardStackItem, index: number, layer: CardStackLayerMotion) => ReactNode; +interface CardStackListProps { + children: (item: T, index: number, layer: CardStackLayerMotion) => ReactNode; } -function CardStackList({ children }: CardStackListProps) { - const { visibleItems, layers, handleExitComplete } = useCardStack(); +function CardStackList({ children }: CardStackListProps) { + const { visibleItems, layers, handleExitComplete } = useCardStack(); return ( @@ -567,7 +577,7 @@ function CardStackList({ children }: CardStackListProps) { ); } -interface CardStackCardProps extends HTMLMotionProps<"article"> { +interface CardStackCardProps extends HTMLMotionProps<"div"> { layer: CardStackLayerMotion; stackIndex: number; stackDepth?: number; @@ -598,12 +608,9 @@ function CardStackCard({ const transition = isPressed ? PRESS_SPRING : layer.transition; return ( - ) { - return
; -} - -interface CardStackAvatarProps extends ComponentProps<"div"> { - src: string; -} - -function CardStackAvatar({ src, className, ...props }: CardStackAvatarProps) { - return ( -
- -
- ); -} - -interface CardStackMetaProps extends ComponentProps<"div"> { - title: string; - tagline: string; -} - -function CardStackMeta({ title, tagline, className, ...props }: CardStackMetaProps) { - return ( -
-

- {title} -

-

- {tagline} -

-
- ); -} - -interface CardStackMediaProps extends Omit, "src" | "alt"> { - src: string; - alt: string; - maskId: CardStackMaskId; - aspect?: CardStackMediaAspect; -} - -function CardStackMedia({ - src, - alt, - maskId, - aspect = "square", - className, - style, - ...props -}: CardStackMediaProps) { - const maskStyle = { - ...CARD_STACK_MASK_STYLE, - maskImage: `url(#${maskId})`, - WebkitMaskImage: `url(#${maskId})`, - WebkitMaskSize: CARD_STACK_MASK_STYLE.maskSize, - WebkitMaskPosition: CARD_STACK_MASK_STYLE.maskPosition, - WebkitMaskRepeat: CARD_STACK_MASK_STYLE.maskRepeat, - ...style, - }; - - return ( -
- {alt} -
- ); -} - -function CardStackBody({ className, ...props }: ComponentProps<"div">) { - return
; -} - -function CardStackFooter({ className, ...props }: ComponentProps<"footer">) { - return ( -
- ); -} - -interface CardStackMetricProps extends ComponentProps<"span"> { - icon: LucideIcon; - label: string; - value: number | string; -} - -function CardStackMetric({ icon: Icon, label, value, className, ...props }: CardStackMetricProps) { - return ( - - - {label}: - {value} - - ); -} - -interface CardStackActionProps extends ComponentProps<"span"> { - icon?: LucideIcon; -} - -function CardStackAction({ icon: Icon, className, children, ...props }: CardStackActionProps) { - return ( - - {children} - {Icon ? : null} - - ); -} - -interface CardStackMasksProps { - className?: string; -} - -function CardStackMasks({ className }: CardStackMasksProps) { - return ; -} - const CardStack = Object.assign(CardStackRoot, { - Frame: CardStackFrame, - Panel: CardStackPanel, - LiveRegion: CardStackLiveRegion, Trigger: CardStackTrigger, Viewport: CardStackViewport, List: CardStackList, Card: CardStackCard, - Header: CardStackHeader, - Avatar: CardStackAvatar, - Meta: CardStackMeta, - Body: CardStackBody, - Media: CardStackMedia, - Footer: CardStackFooter, - Metric: CardStackMetric, - Action: CardStackAction, - Masks: CardStackMasks, -}); +}) as typeof CardStackRoot & { + Trigger: typeof CardStackTrigger; + Viewport: typeof CardStackViewport; + List: typeof CardStackList; + Card: typeof CardStackCard; +}; export default CardStack; export { CardStack, - CardStackAction, - CardStackAvatar, - CardStackBody, CardStackCard, - CardStackFooter, - CardStackFrame, - CardStackHeader, CardStackList, - CardStackLiveRegion, - CardStackMasks, - CardStackMedia, - CardStackMeta, - CardStackMetric, - CardStackPanel, CardStackRoot, CardStackTrigger, CardStackViewport, diff --git a/animata/card/comment-reply-card.tsx b/animata/card/comment-reply-card.tsx index f5b9904f..023a6155 100644 --- a/animata/card/comment-reply-card.tsx +++ b/animata/card/comment-reply-card.tsx @@ -72,10 +72,16 @@ export default function CommentReplyCard({ initialComments }: { initialComments: Comment
- -
@@ -177,6 +183,7 @@ export default function CommentReplyCard({ initialComments }: { initialComments:
+
); diff --git a/animata/card/notify-user-info.tsx b/animata/card/notify-user-info.tsx index 2a17dd35..6d479e01 100644 --- a/animata/card/notify-user-info.tsx +++ b/animata/card/notify-user-info.tsx @@ -1,4 +1,5 @@ "use client"; + import { motion } from "motion/react"; interface NotificationCardProps { diff --git a/animata/card/reminder-scheduler.tsx b/animata/card/reminder-scheduler.tsx index c017a495..86afbaca 100644 --- a/animata/card/reminder-scheduler.tsx +++ b/animata/card/reminder-scheduler.tsx @@ -1,5 +1,7 @@ +"use client"; + import type React from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { cn } from "@/lib/utils"; @@ -19,6 +21,7 @@ const ReminderScheduler: React.FC = ({ daysOfWeek, }) => { const selectedDays = isRepeating ? new Set(["Th", "Fr", "Su"]) : new Set(["Mo", "We", "Sa"]); + const repeatSelectId = useId(); return (
{/* Toggle Switch */} @@ -31,8 +34,11 @@ const ReminderScheduler: React.FC = ({
- + + ); @@ -116,9 +130,11 @@ function SwapText({ const longWord = finalText.length > initialText.length ? finalText : null; return (
-
!disableClick && setActive((current) => !current)} @@ -141,7 +157,7 @@ function SwapText({ > {finalText} -
+
); } diff --git a/animata/card/score-card.tsx b/animata/card/score-card.tsx index 8b853f05..94f5352b 100644 --- a/animata/card/score-card.tsx +++ b/animata/card/score-card.tsx @@ -24,8 +24,9 @@ export default function LiveScoreWidget({ scorer, }: ScoreProps) { const [scored, setScored] = useState(false); - const [awScore, setAwScore] = useState(awayScore); + const [awayScoreDelta, setAwayScoreDelta] = useState(0); const [popAnimation, setPopAnimation] = useState(false); + const awScore = awayScore + awayScoreDelta; return (
@@ -87,12 +88,13 @@ export default function LiveScoreWidget({
diff --git a/animata/carousel/expandable.tsx b/animata/carousel/expandable.tsx index ce1f3672..753f81d9 100644 --- a/animata/carousel/expandable.tsx +++ b/animata/carousel/expandable.tsx @@ -1,4 +1,4 @@ -import { type HTMLAttributes, useEffect, useState } from "react"; +import { type HTMLAttributes, useEffect, useRef, useState } from "react"; import WaveReveal from "@/animata/text/wave-reveal"; import { cn } from "@/lib/utils"; @@ -68,7 +68,7 @@ const items = [ export default function Expandable({ list = items, autoPlay = true, className }: ExpandableProps) { const [activeItem, setActiveItem] = useState(0); - const [isHovering, setIsHovering] = useState(false); + const isHoveringRef = useRef(false); useEffect(() => { if (!autoPlay) { @@ -76,13 +76,13 @@ export default function Expandable({ list = items, autoPlay = true, className }: } const interval = setInterval(() => { - if (!isHovering) { + if (!isHoveringRef.current) { setActiveItem((prev) => (prev + 1) % list.length); } }, 5000); return () => clearInterval(interval); - }, [autoPlay, list.length, isHovering]); + }, [autoPlay, list.length]); return (
@@ -94,10 +94,10 @@ export default function Expandable({ list = items, autoPlay = true, className }: activeItem={activeItem} onMouseEnter={() => { setActiveItem(index); - setIsHovering(true); + isHoveringRef.current = true; }} onMouseLeave={() => { - setIsHovering(false); + isHoveringRef.current = false; }} /> ))} diff --git a/animata/carousel/image-carousel.tsx b/animata/carousel/image-carousel.tsx index 5f1e5c80..a1f2e555 100644 --- a/animata/carousel/image-carousel.tsx +++ b/animata/carousel/image-carousel.tsx @@ -28,18 +28,22 @@ export default function ImageCarousel({ items: initialItems }: IImageCarouselPro return (
-
-
-
+
+ {visibleItems.map((item, index) => (
{/* Button to toggle the small dock open/close */} diff --git a/animata/image/photo-booth.tsx b/animata/image/photo-booth.tsx index 1476d662..e3138421 100644 --- a/animata/image/photo-booth.tsx +++ b/animata/image/photo-booth.tsx @@ -19,7 +19,8 @@ const PhotoBooth = ({ collections, className, ...props }: PhotoBoothProps) => { > photo_booth void; } -const AnimatedImage = forwardRef( - ({ src, onActivityChange }, ref) => { - const controls = useAnimation(); - const isRunning = useRef(false); - const onActivityChangeRef = useRef(onActivityChange); - onActivityChangeRef.current = onActivityChange; - - useImperativeHandle(ref, () => ({ - isActive: () => isRunning.current, - show: async ({ +const AnimatedImage = forwardRef(function AnimatedImage( + { src, onActivityChange }, + ref, +) { + const controls = useAnimation(); + const isRunning = useRef(false); + const onActivityChangeRef = useRef(onActivityChange); + onActivityChangeRef.current = onActivityChange; + + useImperativeHandle(ref, () => ({ + isActive: () => isRunning.current, + show: async ({ + x, + y, + newX, + newY, + zIndex, + }: { + x: number; + y: number; + zIndex: number; + newX: number; + newY: number; + }) => { + controls.stop(); + + controls.set({ + opacity: isRunning.current ? 1 : 0.75, + zIndex, x, y, - newX, - newY, - zIndex, - }: { - x: number; - y: number; - zIndex: number; - newX: number; - newY: number; - }) => { - controls.stop(); - - controls.set({ - opacity: isRunning.current ? 1 : 0.75, - zIndex, - x, - y, + scale: 1, + transition: { ease: "circOut" }, + }); + + isRunning.current = true; + onActivityChangeRef.current?.(1); + + try { + await controls.start({ + opacity: 1, + x: newX, + y: newY, scale: 1, - transition: { ease: "circOut" }, + transition: { duration: 0.9, ease: "circOut" }, }); - isRunning.current = true; - onActivityChangeRef.current?.(1); - - try { - await controls.start({ - opacity: 1, + await Promise.all([ + controls.start({ x: newX, y: newY, - scale: 1, - transition: { duration: 0.9, ease: "circOut" }, - }); - - await Promise.all([ - controls.start({ - x: newX, - y: newY, - scale: 0.1, - transition: { duration: 1, ease: "easeInOut" }, - }), - controls.start({ - opacity: 0, - transition: { duration: 1.1, ease: "easeOut" }, - }), - ]); - } finally { - isRunning.current = false; - onActivityChangeRef.current?.(-1); - } - }, - })); - - return ( - - ); - }, -); + scale: 0.1, + transition: { duration: 1, ease: "easeInOut" }, + }), + controls.start({ + opacity: 0, + transition: { duration: 1.1, ease: "easeOut" }, + }), + ]); + } finally { + isRunning.current = false; + onActivityChangeRef.current?.(-1); + } + }, + })); -AnimatedImage.displayName = "AnimatedImage"; + return ( + + ); +}); const DEFAULT_IMAGES = [ "https://assets.lummi.ai/assets/Qma1aBRXFsApFohRJrpJczE5QXGY6HhHKz24ybuw1khbou?auto=format&w=500", @@ -220,6 +218,15 @@ function useExcludeZoneRects(excludeRefs: RefObject[], enabl return { excludeRectsRef, measureExcludeRects }; } +const EMPTY_EXCLUDE_REFS: RefObject[] = []; + +function createTrailRefs(count: number) { + return Array.from( + { length: count }, + () => createRef() as RefObject, + ); +} + export default function TrailingImage({ images = DEFAULT_IMAGES, className, @@ -229,30 +236,29 @@ export default function TrailingImage({ layerOnly = false, contained = false, contentClassName, - excludeRefs = [], + excludeRefs, maxTrailZIndex, }: TrailingImageProps) { const resolvedImages = images.length > 0 ? images : DEFAULT_IMAGES; + const resolvedExcludeRefs = excludeRefs ?? EMPTY_EXCLUDE_REFS; const containerRef = useRef(null); const trailCount = Math.max(20, resolvedImages.length); - const trailsRef = useRef( - Array.from( - { length: trailCount }, - () => createRef() as RefObject, - ), - ); + const trailsRef = useRef[] | null>(null); + if (!trailsRef.current || trailsRef.current.length !== trailCount) { + trailsRef.current = createTrailRefs(trailCount); + } const lastPosition = useRef({ x: 0, y: 0 }); const cachedPosition = useRef({ x: 0, y: 0 }); const imageIndex = useRef(0); const zIndex = useRef(1); const activeTrailCountRef = useRef(0); - const excludeRefsRef = useRef(excludeRefs); - excludeRefsRef.current = excludeRefs; + const excludeRefsRef = useRef(resolvedExcludeRefs); + excludeRefsRef.current = resolvedExcludeRefs; const maxTrailZIndexRef = useRef(maxTrailZIndex); maxTrailZIndexRef.current = maxTrailZIndex; - const hasExcludeZones = excludeRefs.length > 0; - const { excludeRectsRef } = useExcludeZoneRects(excludeRefs, hasExcludeZones); + const hasExcludeZones = resolvedExcludeRefs.length > 0; + const { excludeRectsRef } = useExcludeZoneRects(resolvedExcludeRefs, hasExcludeZones); const pendingPointerRef = useRef<{ x: number; @@ -300,14 +306,14 @@ export default function TrailingImage({ cachedPosition.current = newCachePosition; if (distance > threshold) { - imageIndex.current = (imageIndex.current + 1) % trailsRef.current.length; + imageIndex.current = (imageIndex.current + 1) % (trailsRef.current?.length ?? 1); const nextZ = zIndex.current + 1; zIndex.current = maxTrailZIndexRef.current !== undefined ? Math.min(nextZ, maxTrailZIndexRef.current) : nextZ; lastPosition.current = cursor; - trailsRef.current[imageIndex.current].current?.show?.({ + trailsRef.current?.[imageIndex.current]?.current?.show?.({ x: newCachePosition.x, y: newCachePosition.y, zIndex: zIndex.current, diff --git a/animata/list/flipping-cards.stories.tsx b/animata/list/flipping-cards.stories.tsx index ee84040f..470a03e6 100644 --- a/animata/list/flipping-cards.stories.tsx +++ b/animata/list/flipping-cards.stories.tsx @@ -1,58 +1,92 @@ import type { Meta, StoryObj } from "@storybook/react"; -import FlippingCard from "@/animata/list/flipping-cards"; +import { PlusCircle } from "lucide-react"; + +import Marquee from "@/animata/container/marquee"; +import FlippingCards, { getFlippingCardsAccent } from "@/animata/list/flipping-cards"; + +const demoItems = [ + { + font: "Antonov AN-255", + title: "Aa", + image: + "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }, + { + font: "Boeing 747", + title: "Bb", + image: + "https://plus.unsplash.com/premium_photo-1717916843908-7bbee16bad20?q=80&w=1964&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }, + { + font: "Cessna 172", + title: "Cc", + image: + "https://images.unsplash.com/photo-1718743256288-e77382a88aaf?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }, + { + font: "Dassault Falcon 7X", + title: "Dd", + image: + "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }, + { + font: "Embraer EMB 120 ", + title: "Ee", + image: + "https://images.unsplash.com/photo-1718792679559-5cfd607bb564?q=80&w=1956&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }, + { + font: "Fokker F100", + title: "Ff", + image: + "https://images.unsplash.com/photo-1718397172443-48185c6bb4e1?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }, +]; const meta = { title: "List/Flipping Cards", - component: FlippingCard, + component: FlippingCards, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: {}, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; export const Primary: Story = { - args: { - list: [ - { - font: "Antonov AN-255", - title: "Aa", - image: - "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - font: "Boeing 747", - title: "Bb", - image: - "https://plus.unsplash.com/premium_photo-1717916843908-7bbee16bad20?q=80&w=1964&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - font: "Cessna 172", - title: "Cc", - image: - "https://images.unsplash.com/photo-1718743256288-e77382a88aaf?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - font: "Dassault Falcon 7X", - title: "Dd", - image: - "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - font: "Embraer EMB 120 ", - title: "Ee", - image: - "https://images.unsplash.com/photo-1718792679559-5cfd607bb564?q=80&w=1956&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - font: "Fokker F100", - title: "Ff", - image: - "https://images.unsplash.com/photo-1718397172443-48185c6bb4e1?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - ], - }, + render: () => ( + + {demoItems.map((item, index) => ( + + +
+ {item.font} + + {item.title} + +
+ {index + 1} + +
+
+
+ + + + {item.font.split(" ")[0]} + +
+ See more + +
+
+
+ ))} +
+ ), }; diff --git a/animata/list/flipping-cards.tsx b/animata/list/flipping-cards.tsx index 6f59c151..2479d028 100644 --- a/animata/list/flipping-cards.tsx +++ b/animata/list/flipping-cards.tsx @@ -1,92 +1,36 @@ -import { PlusCircle } from "lucide-react"; +import type { ComponentProps } from "react"; -import Marquee from "@/animata/container/marquee"; +import FlipCard, { FlipCardBack, FlipCardFront } from "@/animata/card/flip-card"; import { cn } from "@/lib/utils"; -interface CardProps { - show: React.ReactNode; - reveal: React.ReactNode; -} +type FlippingCardsRootProps = ComponentProps<"div">; -interface CardDetailsProps extends React.HTMLAttributes { - title: string; - font: string; - image: string; - index?: number; +function FlippingCardsRoot({ className, ...props }: FlippingCardsRootProps) { + return
; } -interface FlippingCardProps { - list: CardDetailsProps[]; -} +type FlippingCardsItemProps = ComponentProps; -const Card = ({ show, reveal }: CardProps) => { - const common = "absolute flex w-full h-full [backface-visibility:hidden]"; +function FlippingCardsItem({ className, ...props }: FlippingCardsItemProps) { return ( -
-
-
{show}
-
- {reveal} -
-
-
+ div]:rounded-none", className)} rotate="y" {...props} /> ); -}; +} -const CardDetails = ({ title, image, font, index }: CardDetailsProps) => { - return ( - - {font} +const FlippingCardsItemWithFaces = Object.assign(FlippingCardsItem, { + Front: FlipCardFront, + Back: FlipCardBack, +}); - - {title} - -
- {(index ?? 0) + 1} - -
-
- } - reveal={ -
- - - {font.split(" ")[0]} - -
- See more - -
-
- } - /> - ); +const FlippingCards = Object.assign(FlippingCardsRoot, { + Item: FlippingCardsItemWithFaces, +}) as typeof FlippingCardsRoot & { + Item: typeof FlippingCardsItemWithFaces; }; -export default function FlippingCard({ list }: FlippingCardProps) { - return ( -
- {list.map((item, index) => ( - - ))} -
- ); +export function getFlippingCardsAccent(_index: number) { + return "hsl(var(--accent))"; } + +export default FlippingCards; +export { FlippingCards, FlippingCardsItemWithFaces as FlippingCardsItem }; diff --git a/animata/overlay/modal.tsx b/animata/overlay/modal.tsx index 46830c9c..c775dfad 100644 --- a/animata/overlay/modal.tsx +++ b/animata/overlay/modal.tsx @@ -3,7 +3,6 @@ import { CircleAlert } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; - import { cn } from "@/lib/utils"; export default function Modal({ modalSize = "lg" }: { modalSize?: "sm" | "lg" }) { @@ -11,6 +10,7 @@ export default function Modal({ modalSize = "lg" }: { modalSize?: "sm" | "lg" }) return (
diff --git a/animata/skeleton/list.tsx b/animata/skeleton/list.tsx index e8e70cec..8acd6b55 100644 --- a/animata/skeleton/list.tsx +++ b/animata/skeleton/list.tsx @@ -10,11 +10,11 @@ export default function List() { ))}
- -
diff --git a/animata/skeleton/wide-card.tsx b/animata/skeleton/wide-card.tsx index fa222dd7..816abb14 100644 --- a/animata/skeleton/wide-card.tsx +++ b/animata/skeleton/wide-card.tsx @@ -12,7 +12,7 @@ export default function WideCard() {
-
diff --git a/animata/tabs/fluid-tabs.tsx b/animata/tabs/fluid-tabs.tsx index 5ae8a7c9..01a84358 100644 --- a/animata/tabs/fluid-tabs.tsx +++ b/animata/tabs/fluid-tabs.tsx @@ -11,6 +11,7 @@ import { type ReactNode, use, useId, + useMemo, } from "react"; import { cn } from "@/lib/utils"; import { @@ -56,6 +57,11 @@ function useFluidTabs() { return context; } +function FluidTabSlot({ index, children }: { index: number; children: ReactNode }) { + const value = useMemo(() => ({ index }), [index]); + return {children}; +} + function useFluidTabSlot() { const context = use(FluidTabSlotContext); if (!context) { @@ -86,16 +92,19 @@ function FluidTabsRoot({ }); const indicatorLayoutId = `fluid-tab-indicator-${useId().replace(/:/g, "")}`; + const rootContext = useMemo( + () => ({ + activeIndex, + setActiveIndex, + focusedIndex, + setFocusedIndex, + indicatorLayoutId, + }), + [activeIndex, setActiveIndex, focusedIndex, setFocusedIndex, indicatorLayoutId], + ); + return ( - +
{children}
@@ -127,6 +136,7 @@ function FluidTabsList({ >
) => { onFocusCapture?.(event); handleTabListFocusCapture(event, activeIndex, setFocusedIndex); @@ -140,9 +150,9 @@ function FluidTabsList({ className="flex w-full gap-1" > {tabs.map((tab, index) => ( - + {tab} - + ))}
diff --git a/animata/tabs/gooey-tabs.tsx b/animata/tabs/gooey-tabs.tsx index c885262c..7ea065fc 100644 --- a/animata/tabs/gooey-tabs.tsx +++ b/animata/tabs/gooey-tabs.tsx @@ -1,6 +1,7 @@ "use client"; -import { type HTMLMotionProps, motion } from "motion/react"; +import type { HTMLMotionProps } from "motion/react"; +import { motion } from "motion/react"; import { Children, type ComponentProps, @@ -11,6 +12,7 @@ import { type ReactNode, use, useId, + useMemo, } from "react"; import { cn } from "@/lib/utils"; import { @@ -48,6 +50,19 @@ function useGooeyTabs() { return context; } +function GooeyTabSlot({ + index, + count, + children, +}: { + index: number; + count: number; + children: ReactNode; +}) { + const value = useMemo(() => ({ index, count }), [index, count]); + return {children}; +} + function useGooeyTabSlot() { const context = use(GooeyTabSlotContext); if (!context) { @@ -140,19 +155,31 @@ function GooeyTabsRoot({ }); const filterId = `gooey-tabs-${useId().replace(/:/g, "")}`; + const rootContext = useMemo( + () => ({ + activeIndex, + setActiveIndex, + focusedIndex, + setFocusedIndex, + filterId, + intensity, + contrast, + lightness, + }), + [ + activeIndex, + setActiveIndex, + focusedIndex, + setFocusedIndex, + filterId, + intensity, + contrast, + lightness, + ], + ); + return ( - +
{children}
); @@ -186,6 +213,7 @@ function GooeyTabsList({ diff --git a/animata/tabs/shared.ts b/animata/tabs/shared.ts index a6a8fee7..d4d0baf9 100644 --- a/animata/tabs/shared.ts +++ b/animata/tabs/shared.ts @@ -1,4 +1,4 @@ -import { type FocusEvent, type KeyboardEvent, useEffect, useState } from "react"; +import { type FocusEvent, type KeyboardEvent, useCallback, useState } from "react"; import { cn } from "@/lib/utils"; @@ -21,24 +21,24 @@ export function useTabSelection({ onActiveIndexChange?: (index: number) => void; }) { const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultActiveIndex); - const [focusedIndex, setFocusedIndex] = useState( - defaultActiveIndex >= 0 ? defaultActiveIndex : 0, - ); + const [keyboardFocusIndex, setKeyboardFocusIndex] = useState(null); const activeIndex = activeIndexProp ?? uncontrolledIndex; + const focusedIndex = keyboardFocusIndex ?? (activeIndex >= 0 ? activeIndex : 0); + + const setActiveIndex = useCallback( + (index: number) => { + onActiveIndexChange?.(index); + if (activeIndexProp === undefined) { + setUncontrolledIndex(index); + } + setKeyboardFocusIndex(null); + }, + [activeIndexProp, onActiveIndexChange], + ); - const setActiveIndex = (index: number) => { - onActiveIndexChange?.(index); - if (activeIndexProp === undefined) { - setUncontrolledIndex(index); - } - setFocusedIndex(index); - }; - - useEffect(() => { - if (activeIndex >= 0) { - setFocusedIndex(activeIndex); - } - }, [activeIndex]); + const setFocusedIndex = useCallback((index: number) => { + setKeyboardFocusIndex(index); + }, []); return { activeIndex, setActiveIndex, focusedIndex, setFocusedIndex }; } diff --git a/animata/tabs/shift-tabs.tsx b/animata/tabs/shift-tabs.tsx index 5ed69219..2f93ad01 100644 --- a/animata/tabs/shift-tabs.tsx +++ b/animata/tabs/shift-tabs.tsx @@ -9,6 +9,7 @@ import { type KeyboardEvent, type ReactNode, use, + useMemo, } from "react"; import { cn } from "@/lib/utils"; import { @@ -41,6 +42,11 @@ function useShiftTabs() { return context; } +function ShiftTabSlot({ index, children }: { index: number; children: ReactNode }) { + const value = useMemo(() => ({ index }), [index]); + return {children}; +} + function useShiftTabSlot() { const context = use(ShiftTabSlotContext); if (!context) { @@ -70,10 +76,13 @@ function ShiftTabsRoot({ onActiveIndexChange, }); + const rootContext = useMemo( + () => ({ activeIndex, setActiveIndex, focusedIndex, setFocusedIndex }), + [activeIndex, setActiveIndex, focusedIndex, setFocusedIndex], + ); + return ( - +
{children}
); @@ -99,6 +108,7 @@ function ShiftTabsList({ diff --git a/animata/text/circular-text.tsx b/animata/text/circular-text.tsx index 3b424b71..63015674 100644 --- a/animata/text/circular-text.tsx +++ b/animata/text/circular-text.tsx @@ -1,6 +1,5 @@ import { motion } from "motion/react"; import { useMemo } from "react"; - import { cn } from "@/lib/utils"; export default function CircularText({ diff --git a/animata/text/counter.tsx b/animata/text/counter.tsx index b4156d64..f290ef5d 100644 --- a/animata/text/counter.tsx +++ b/animata/text/counter.tsx @@ -1,4 +1,4 @@ -import { useInView, useMotionValue, useSpring } from "motion/react"; +import { useInView, useMotionValue, useMotionValueEvent, useSpring } from "motion/react"; import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; @@ -68,13 +68,11 @@ export default function Counter({ return () => clearTimeout(timer); }, [isInView, delay, isGoingUp, targetValue, motionValue]); - useEffect(() => { - springValue.on("change", (value) => { - if (ref.current) { - ref.current.textContent = format ? format(value) : String(value); - } - }); - }, [springValue, format]); + useMotionValueEvent(springValue, "change", (value) => { + if (ref.current) { + ref.current.textContent = format ? format(value) : String(value); + } + }); const initialDisplay = format ? format(isGoingUp ? 0 : targetValue) diff --git a/animata/text/gibberish-text.tsx b/animata/text/gibberish-text.tsx index d3ad87fe..0456e2cc 100644 --- a/animata/text/gibberish-text.tsx +++ b/animata/text/gibberish-text.tsx @@ -15,7 +15,7 @@ interface GibberishTextProps { } const Letter = ({ letter, className }: { letter: string; className?: string }) => { - const [code, setCode] = useState(letter.toUpperCase().charCodeAt(0)); + const [code, setCode] = useState(() => letter.toUpperCase().charCodeAt(0)); useEffect(() => { let count = Math.floor(Math.random() * 10) + 5; diff --git a/animata/text/glitch-text.css b/animata/text/glitch-text.css index 44156aca..edbc2954 100644 --- a/animata/text/glitch-text.css +++ b/animata/text/glitch-text.css @@ -3,7 +3,7 @@ --glitch-intensity: 5; --glitch-step: 0.01em; --glitch-duration: 2.5s; - --glitch-color-base: #ffffff; + --glitch-color-base: var(--text-foreground); --glitch-color-a: #ff00ff; --glitch-color-b: #00ffff; --glitch-blend-mode: screen; diff --git a/animata/text/jitter-text.tsx b/animata/text/jitter-text.tsx index bcb75aad..c7b04e9a 100644 --- a/animata/text/jitter-text.tsx +++ b/animata/text/jitter-text.tsx @@ -1,5 +1,4 @@ import { motion } from "motion/react"; - import { cn } from "@/lib/utils"; interface JitteryTextProps { diff --git a/animata/text/roll-text.tsx b/animata/text/roll-text.tsx index 34769c90..0e71c7a9 100644 --- a/animata/text/roll-text.tsx +++ b/animata/text/roll-text.tsx @@ -175,16 +175,14 @@ export default function RollText({ segmentCountRef.current = segments.length; disabledRef.current = disabled; + if (disabled && phase !== "closed") { + setPhase("closed"); + } + useEffect(() => { phaseRef.current = phase; }, [phase]); - useEffect(() => { - if (disabled && phase !== "closed") { - setPhase("closed"); - } - }, [disabled, phase]); - useEffect(() => { const media = window.matchMedia("(prefers-reduced-motion: reduce)"); reducedMotionRef.current = media.matches; @@ -244,13 +242,16 @@ export default function RollText({ if (!groupHover && !disabled) playOpen(); }; - const handleStackAnimationEnd = (event: React.AnimationEvent) => { - onAnimationEnd?.(event); - if (phaseRef.current !== "animating") return; + const handleStackAnimationEnd = useCallback( + (event: React.AnimationEvent) => { + onAnimationEnd?.(event); + if (phaseRef.current !== "animating") return; - remainingRef.current -= 1; - if (remainingRef.current <= 0) setPhase("open"); - }; + remainingRef.current -= 1; + if (remainingRef.current <= 0) setPhase("open"); + }, + [onAnimationEnd], + ); return ( -
!disableClick && setActive((current) => !current)} > {finalText} -
+
); } diff --git a/animata/text/text-animator-demo.css b/animata/text/text-animator-demo.css index 484a51b8..b6de2a63 100644 --- a/animata/text/text-animator-demo.css +++ b/animata/text/text-animator-demo.css @@ -1,23 +1,25 @@ -/* Demo typography for preset previews — globals + text-animator.tsx + text-animator-list-demo.tsx */ -.text-animator-demo .text-animation-stage, -[data-text-animator-demo] .text-animation-stage { - padding: clamp(1rem, 5cqi, 1.5rem); - perspective: 900px; - font-size: clamp(1rem, 10cqi, 2rem); - font-weight: 500; - line-height: 1.1; -} +@layer components { + /* Demo typography for preset previews — loaded via styles/globals.css */ + .text-animator-demo .text-animation-stage, + [data-text-animator-demo] .text-animation-stage { + padding: clamp(1rem, 5cqi, 1.5rem); + font-size: clamp(1rem, 10cqi, 2rem); + perspective: 900px; + font-weight: 500; + line-height: 1.1; + } -.text-animator-demo .text-animation-title, -.text-animator-demo .text-animation-kinetic-word, -[data-text-animator-demo] .text-animation-title, -[data-text-animator-demo] .text-animation-kinetic-word { - text-align: center; - letter-spacing: -0.025em; -} + .text-animator-demo .text-animation-title, + .text-animator-demo .text-animation-kinetic-word, + [data-text-animator-demo] .text-animation-title, + [data-text-animator-demo] .text-animation-kinetic-word { + text-align: center; + letter-spacing: -0.025em; + } -.text-animator-demo .text-animation-title, -[data-text-animator-demo] .text-animation-title { - text-wrap: balance; - font-variant-numeric: tabular-nums; + .text-animator-demo .text-animation-title, + [data-text-animator-demo] .text-animation-title { + text-wrap: balance; + font-variant-numeric: tabular-nums; + } } diff --git a/animata/text/text-animator-demo.ts b/animata/text/text-animator-demo.ts index c608da19..cb2159fe 100644 --- a/animata/text/text-animator-demo.ts +++ b/animata/text/text-animator-demo.ts @@ -1,4 +1,4 @@ -/** Demo frame classes — typography lives in co-located text-animator-demo.css (imported by text-animator.tsx). */ +/** Demo frame classes — typography lives in co-located text-animator-demo.css (imported via styles/globals.css). */ export const TEXT_ANIMATOR_DEMO_CLASS = "text-animator-demo"; /** Storybook frame — bordered tile matching doc previews. */ diff --git a/animata/text/text-animator.css b/animata/text/text-animator.css index ae5d7f17..5f8b32bb 100644 --- a/animata/text/text-animator.css +++ b/animata/text/text-animator.css @@ -1,55 +1,35 @@ +@reference "tailwindcss"; + @layer components { .text-animation-stage { - container-type: inline-size; - display: grid; - height: 100%; - min-height: 0; - width: 100%; - place-items: center; - grid-template-areas: "title"; + @apply @container grid h-full min-h-0 w-full place-items-center [grid-template-areas:'title']; } .text-animation-title { - margin: 0; - grid-area: title; - pointer-events: none; - transform-style: preserve-3d; + @apply m-0 pointer-events-none transform-3d [grid-area:title]; } .text-animation-unit { - display: inline-block; - white-space: pre; - backface-visibility: hidden; - transform-origin: 50% 55%; - will-change: transform, opacity, filter; + @apply inline-block whitespace-pre backface-hidden origin-[50%_55%] [will-change:transform,opacity,filter]; } .text-animation-unit.line { - display: block; + @apply block; } .text-animation-kinetic-line { - position: relative; - height: 72px; - width: min(92%, 270px); + @apply relative h-[72px] w-[min(92%,270px)]; } .text-animation-kinetic-stack { - position: relative; - height: 132px; - width: min(92%, 270px); + @apply relative h-[132px] w-[min(92%,270px)]; } .text-animation-kinetic-word { - position: absolute; - left: 50%; - top: 50%; - white-space: nowrap; - backface-visibility: hidden; - will-change: transform, opacity, filter; + @apply absolute top-1/2 left-1/2 whitespace-nowrap backface-hidden [will-change:transform,opacity,filter]; } .text-animation-fallback { - opacity: 0.74; + @apply opacity-[0.74]; } } diff --git a/animata/text/text-animator.tsx b/animata/text/text-animator.tsx index b9913a09..80f3cafa 100644 --- a/animata/text/text-animator.tsx +++ b/animata/text/text-animator.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { TEXT_ANIMATOR_DEMO_CLASS } from "@/animata/text/text-animator-demo"; import { cn } from "@/lib/utils"; -import "./text-animator-demo.css"; import "./text-animator.css"; export type TextAnimationTarget = "whole" | "per-character" | "per-word" | "per-line"; @@ -925,6 +924,15 @@ export default function TextAnimator({ // unlikely to appear in user-supplied copy. const samplesKey = useMemo(() => samples?.join("") ?? "", [samples]); const phrasesKey = useMemo(() => phrases?.map((p) => p.join("")).join("") ?? "", [phrases]); + const contentKey = `${samplesKey}|${phrasesKey}`; + const prevContentKeyRef = useRef(contentKey); + + if (contentKey !== prevContentKeyRef.current) { + prevContentKeyRef.current = contentKey; + if (failed) { + setFailed(false); + } + } // Refs let the effect read the latest samples/phrases without listing them // as deps (we drive re-runs via the content keys above). @@ -939,7 +947,6 @@ export default function TextAnimator({ useEffect(() => { const stage = stageRef.current; if (!stage) return; - setFailed(false); if (spec.id) stage.dataset.animationId = spec.id; const runtime = { diff --git a/animata/text/ticker.tsx b/animata/text/ticker.tsx index 7fdaeb9c..a00226a5 100644 --- a/animata/text/ticker.tsx +++ b/animata/text/ticker.tsx @@ -1,6 +1,7 @@ "use client"; import { motion, useInView, useMotionValue, useSpring } from "motion/react"; + import { useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; diff --git a/animata/text/typing-text.tsx b/animata/text/typing-text.tsx index 40d79571..3cb8388c 100644 --- a/animata/text/typing-text.tsx +++ b/animata/text/typing-text.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; @@ -162,54 +162,63 @@ function Type({ hideCursorOnComplete, }: TypingTextProps) { const [index, setIndex] = useState(0); - const [direction, setDirection] = useState(TypingDirection.Forward); - const [isComplete, setIsComplete] = useState(false); + const directionRef = useRef(TypingDirection.Forward); + const onCompleteRef = useRef(onComplete); + const completedRef = useRef(false); + onCompleteRef.current = onComplete; const words = useMemo(() => text.split(/\s+/), [text]); const total = smooth ? words.length : text.length; + const isComplete = index === total && !repeat; useEffect(() => { - // eslint-disable-next-line prefer-const - let interval: NodeJS.Timeout; + let interval: ReturnType | undefined; + let timeout: ReturnType | undefined; - const startTyping = () => { - setIndex((prevDir) => { - if (direction === TypingDirection.Backward && prevDir === TypingDirection.Forward) { - clearInterval(interval); - } else if (direction === TypingDirection.Forward && prevDir === total - 1) { - clearInterval(interval); - } - return prevDir + direction; - }); - }; + const startInterval = () => { + interval = setInterval(() => { + setIndex((current) => { + const direction = directionRef.current; + const next = current + direction; - interval = setInterval(startTyping, delay); - return () => clearInterval(interval); - }, [total, direction, delay]); + if (direction === TypingDirection.Forward && next >= total) { + if (!repeat) { + if (!completedRef.current) { + completedRef.current = true; + onCompleteRef.current?.(); + } + if (interval) clearInterval(interval); + return total; + } + if (interval) clearInterval(interval); + timeout = setTimeout(() => { + directionRef.current = TypingDirection.Backward; + startInterval(); + }, waitTime); + return total; + } - useEffect(() => { - let timeout: NodeJS.Timeout; + if (direction === TypingDirection.Backward && next <= 0) { + if (interval) clearInterval(interval); + timeout = setTimeout(() => { + directionRef.current = TypingDirection.Forward; + startInterval(); + }, waitTime); + return 0; + } - if (index >= total && repeat) { - timeout = setTimeout(() => { - setDirection(-1); - }, waitTime); - } + return next; + }); + }, delay); + }; - if (index <= 0 && repeat) { - timeout = setTimeout(() => { - setDirection(1); - }, waitTime); - } - return () => clearTimeout(timeout); - }, [index, total, repeat, waitTime]); + startInterval(); - useEffect(() => { - if (index === total && !repeat) { - setIsComplete(true); - onComplete?.(); - } - }, [index, total, repeat, onComplete]); + return () => { + if (interval) clearInterval(interval); + if (timeout) clearTimeout(timeout); + }; + }, [total, delay, repeat, waitTime]); const waitingNextCycle = index === total || index === 0; diff --git a/animata/widget/calendar-widget.tsx b/animata/widget/calendar-widget.tsx index e5bfb235..6fbaa8de 100644 --- a/animata/widget/calendar-widget.tsx +++ b/animata/widget/calendar-widget.tsx @@ -1,6 +1,6 @@ import { Calendar as CalendarIcon, DotIcon } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; const monthArray = [ "", @@ -24,10 +24,12 @@ interface EventType { time: string; } +const EMPTY_CALENDAR_EVENTS: EventType[] = []; + export default function CalendarWidget({ initialSelectedDate = 1, initialShowEvents = true, - eventsData = [], + eventsData, month = 1, year = new Date().getFullYear(), }: { @@ -37,6 +39,7 @@ export default function CalendarWidget({ month?: number; year?: number; }) { + const resolvedEvents = eventsData ?? EMPTY_CALENDAR_EVENTS; const [selectedDate, setSelectedDate] = useState(initialSelectedDate); const [showEvents, setShowEvents] = useState(initialShowEvents); const scrollRef = useRef(null); @@ -44,16 +47,12 @@ export default function CalendarWidget({ const dates = Array.from({ length: 30 }, (_, i) => i + 1); const daySymbols = ["S", "M", "T", "W", "T", "F", "S"]; - const filteredEvents = eventsData.filter((event: EventType) => event.date === selectedDate); + const filteredEvents = resolvedEvents.filter((event: EventType) => event.date === selectedDate); - useEffect(() => { - if (scrollRef.current) { - const selectedElement = scrollRef.current.querySelector(`[data-date="${selectedDate}"]`); - if (selectedElement) { - selectedElement.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }); - } - } - }, [selectedDate]); + const scrollToDate = (date: number) => { + const selectedElement = scrollRef.current?.querySelector(`[data-date="${date}"]`); + selectedElement?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }); + }; return ( { setSelectedDate(date); + scrollToDate(date); setShowEvents(true); }} whileHover={{ scale: 1.05 }} @@ -100,7 +100,7 @@ export default function CalendarWidget({ )} - {eventsData.find((alldates: EventType) => alldates.date === date) ? ( + {eventsData?.find((alldates: EventType) => alldates.date === date) ? ( ) : ( "" diff --git a/animata/widget/clock-with-photo.tsx b/animata/widget/clock-with-photo.tsx index ae24ef15..a39b174c 100644 --- a/animata/widget/clock-with-photo.tsx +++ b/animata/widget/clock-with-photo.tsx @@ -1,6 +1,5 @@ -/* eslint-disable @next/next/no-img-element */ "use client"; - +/* eslint-disable @next/next/no-img-element */ import { useEffect, useState } from "react"; import { absoluteUrl, cn } from "@/lib/utils"; @@ -19,7 +18,7 @@ const getTime = () => { }; export default function ClockWithPhoto() { - const [time, setTime] = useState(getTime()); + const [time, setTime] = useState(getTime); useEffect(() => { let timeout: NodeJS.Timeout; @@ -30,7 +29,7 @@ export default function ClockWithPhoto() { timeout = setTimeout(updateTime, secondsUntilNextMinute * 1000); }; - updateTime(); + timeout = setTimeout(updateTime, 0); return () => clearTimeout(timeout); }, []); @@ -45,7 +44,7 @@ export default function ClockWithPhoto() { /> Your photo
diff --git a/animata/widget/delivery-card.tsx b/animata/widget/delivery-card.tsx index a9c6fcbf..ff2460fa 100644 --- a/animata/widget/delivery-card.tsx +++ b/animata/widget/delivery-card.tsx @@ -1,7 +1,7 @@ "use client"; import { LocateIcon, TruckIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; @@ -23,16 +23,21 @@ export default function DeliveryCard({ timeAgo = "30 min", simulateProgress = false, }: DeliveryCardProps) { - const [demoProgress, setDemoProgress] = useState(progress); + const [demoProgress, setDemoProgress] = useState(0); + const simulateRef = useRef(simulateProgress); + + if (simulateProgress !== simulateRef.current) { + simulateRef.current = simulateProgress; + if (simulateProgress) { + setDemoProgress(progress); + } + } + const displayProgress = simulateProgress ? demoProgress : progress; const clamped = Math.min(100, Math.max(0, displayProgress)); const status = clamped <= 0 ? "Processing" : clamped >= 100 ? "Delivered" : "In transit"; const headline = clamped >= 100 ? "Arrived" : "Arrives today"; - useEffect(() => { - setDemoProgress(progress); - }, [progress]); - useEffect(() => { if (!simulateProgress) return; const id = setInterval(() => { @@ -79,7 +84,7 @@ export default function DeliveryCard({ className="relative h-0.5 bg-amber-400 transition-[width] duration-500 ease-in-out dark:bg-amber-300" style={{ width: `${clamped}%` }} > - +
diff --git a/animata/widget/direction-card.tsx b/animata/widget/direction-card.tsx index 1dc509fa..8422f234 100644 --- a/animata/widget/direction-card.tsx +++ b/animata/widget/direction-card.tsx @@ -49,52 +49,36 @@ function DirectionCard({ directionValues = testDirectionProps.directionValues, duration = 5000, }: IDirectionCardProps) { - const [currentIndex, setCurrentIndex] = useState(0); - const [iconState, setIconState] = useState({ - prevIconType: directionValues[directionValues.length - 1].iconType, - currentIconType: directionValues[0].iconType, - nextIconType: directionValues[1].iconType, - }); - const [progress, setProgress] = useState(0); + const [directionState, setDirectionState] = useState({ currentIndex: 0, progress: 0 }); + const { currentIndex, progress } = directionState; + const directionCount = directionValues.length; + + const prevIconType = + directionValues[(currentIndex - 1 + directionCount) % directionCount].iconType; + const currentIconType = directionValues[currentIndex].iconType; + const nextIconType = directionValues[(currentIndex + 1) % directionCount].iconType; useEffect(() => { - //this would change the states based on direction change. Currently set to setInterval. const changeDirectionInterval = setInterval(() => { - setCurrentIndex((prevIndex) => { - const newIndex = (prevIndex + 1) % directionValues.length; - const prev = - newIndex === 0 - ? directionValues[directionValues.length - 1].iconType - : directionValues[newIndex - 1].iconType; - const next = - newIndex === directionValues.length - 1 - ? directionValues[0].iconType - : directionValues[newIndex + 1].iconType; - setIconState({ - prevIconType: prev, - currentIconType: directionValues[newIndex].iconType, - nextIconType: next, - }); - return newIndex; - }); - setProgress(0); + setDirectionState((state) => ({ + currentIndex: (state.currentIndex + 1) % directionCount, + progress: 0, + })); }, duration ?? 5000); const progressIncrement = 100 / ((duration ?? 5000) / 100); const progressInterval = setInterval(() => { - setProgress((prevProgress) => { - if (prevProgress >= 100) { - return 100; - } - return prevProgress + progressIncrement; - }); + setDirectionState((state) => ({ + ...state, + progress: state.progress >= 100 ? 100 : state.progress + progressIncrement, + })); }, 100); return () => { clearInterval(changeDirectionInterval); clearInterval(progressInterval); }; - }, [directionValues, duration]); + }, [duration, directionCount]); const currentDirection = directionValues[currentIndex]; @@ -108,7 +92,7 @@ function DirectionCard({ {currentDirection.distance} m

-

{renderIcon(iconState.currentIconType, 52, "text-white")}

+

{renderIcon(currentIconType, 52, "text-white")}

{currentDirection.to}

@@ -119,9 +103,9 @@ function DirectionCard({ style={{ boxShadow: "inset 0px -30px 20px 0px black" }} className="absolute inset-0 shadow" /> - {renderIcon(iconState.prevIconType, 32)} - {renderIcon(iconState.currentIconType, 32, "text-green-300")} - {renderIcon(iconState.nextIconType, 32)} + {renderIcon(prevIconType, 32)} + {renderIcon(currentIconType, 32, "text-green-300")} + {renderIcon(nextIconType, 32)}
acc + item.amount, 0); + const periodLabel = useMemo(() => { + const now = new Date(); + return `${now.toLocaleString("default", { month: "long" }).toUpperCase()} ${now.getFullYear()}`; + }, []); return (
-

- {new Date().toLocaleString("default", { month: "long" }).toUpperCase()}{" "} - {new Date().getFullYear()} -

+

{periodLabel}

{spending.map((item, index) => (
{ }; export default function FlightWidget() { - const [formattedTime, setFormattedTime] = useState(getTime()); + const [formattedTime, setFormattedTime] = useState(() => getTime()); useEffect(() => { const now = new Date(); diff --git a/animata/widget/fund-widget.tsx b/animata/widget/fund-widget.tsx index 166a3e94..01e8e178 100644 --- a/animata/widget/fund-widget.tsx +++ b/animata/widget/fund-widget.tsx @@ -1,4 +1,5 @@ -import { AnimatePresence, motion, type PanInfo } from "motion/react"; +import type { PanInfo } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; diff --git a/animata/widget/live-score.tsx b/animata/widget/live-score.tsx index 6fee71f0..a9ff4c29 100644 --- a/animata/widget/live-score.tsx +++ b/animata/widget/live-score.tsx @@ -110,9 +110,9 @@ export default function LiveScore() { }); }; - updateScore(); + const timeoutId = setTimeout(updateScore, 0); - return () => clearTimeout(timer); + return () => clearTimeout(timeoutId); }, []); // #endregion diff --git a/animata/widget/music-stack-interaction.tsx b/animata/widget/music-stack-interaction.tsx index 68f064a0..cdd8a4b0 100644 --- a/animata/widget/music-stack-interaction.tsx +++ b/animata/widget/music-stack-interaction.tsx @@ -104,22 +104,34 @@ export default function MusicStackInteraction({ albums }: albumsProps) {
-
-
-
+
+
diff --git a/animata/widget/reminder-widget.tsx b/animata/widget/reminder-widget.tsx index d0104272..b844b11a 100644 --- a/animata/widget/reminder-widget.tsx +++ b/animata/widget/reminder-widget.tsx @@ -1,7 +1,7 @@ "use client"; import { Bell } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { cn } from "@/lib/utils"; @@ -33,7 +33,13 @@ export default function ReminderWidget({ title = "Packing", items = DEFAULT_ITEMS, }: ReminderWidgetProps) { - const [rows, setRows] = useState(items); + const itemsRef = useRef(null); + const [rows, setRows] = useState([]); + + if (items !== itemsRef.current) { + itemsRef.current = items; + setRows(items); + } const remaining = rows.filter((item) => !item.done).length; diff --git a/animata/widget/shopping-list.tsx b/animata/widget/shopping-list.tsx index 654f5781..ed9f414b 100644 --- a/animata/widget/shopping-list.tsx +++ b/animata/widget/shopping-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { cn } from "@/lib/utils"; @@ -50,7 +50,13 @@ export default function ShoppingList({ title = "Shopping list", items = DEFAULT_ITEMS, }: ShoppingListProps) { - const [rows, setRows] = useState(items); + const itemsRef = useRef(null); + const [rows, setRows] = useState([]); + + if (items !== itemsRef.current) { + itemsRef.current = items; + setRows(items); + } const toggle = (id: string) => { setRows((prev) => diff --git a/animata/widget/team-clock.tsx b/animata/widget/team-clock.tsx index e3b9b7ca..33ad0801 100644 --- a/animata/widget/team-clock.tsx +++ b/animata/widget/team-clock.tsx @@ -1,8 +1,7 @@ "use client"; import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useMemo, useRef, useState } from "react"; - +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import { cn } from "@/lib/utils"; interface TeamClockProps { @@ -24,6 +23,79 @@ interface TeamClockProps { use24HourFormat: boolean; } +type TeamClockState = { + isExpanded: boolean; + angle: number; + currentTime: Date; + isMobile: boolean; + selectedUser: string | null; + hoveredUser: string | null; +}; + +type TeamClockAction = + | { type: "toggle" } + | { type: "tick"; time: Date } + | { type: "setMobile"; isMobile: boolean } + | { type: "selectUser"; userName: string; timeDifference: string; users: TeamClockProps["users"] } + | { + type: "hoverUser"; + userName: string | null; + timeDifference: string | null; + users: TeamClockProps["users"]; + }; + +function teamClockReducer(state: TeamClockState, action: TeamClockAction): TeamClockState { + switch (action.type) { + case "toggle": + return { ...state, isExpanded: !state.isExpanded }; + case "tick": + return { ...state, currentTime: action.time }; + case "setMobile": + return { ...state, isMobile: action.isMobile }; + case "selectUser": { + if (state.selectedUser === action.userName) { + return { ...state, selectedUser: null, angle: 0 }; + } + return { + ...state, + selectedUser: action.userName, + angle: parseInt(action.timeDifference, 10) * 30, + }; + } + case "hoverUser": { + if (action.userName && action.timeDifference) { + return { + ...state, + hoveredUser: action.userName, + angle: parseInt(action.timeDifference, 10) * 30, + }; + } + if (!state.selectedUser) { + return { ...state, hoveredUser: null, angle: 0 }; + } + const selectedUserData = action.users.find((user) => user.name === state.selectedUser); + return { + ...state, + hoveredUser: null, + angle: selectedUserData ? parseInt(selectedUserData.timeDifference, 10) * 30 : 0, + }; + } + default: + return state; + } +} + +function createInitialTeamClockState(): TeamClockState { + return { + isExpanded: false, + angle: 0, + currentTime: new Date(), + isMobile: typeof window !== "undefined" ? window.innerWidth < 768 : false, + selectedUser: null, + hoveredUser: null, + }; +} + export default function TeamClock({ users, clockSize, @@ -36,19 +108,14 @@ export default function TeamClock({ showSeconds = false, use24HourFormat = false, }: TeamClockProps) { - const [isExpanded, setIsExpanded] = useState(false); - const [angle, setAngle] = useState(0); - const [currentTime, setCurrentTime] = useState(new Date()); - const [isMobile, setIsMobile] = useState(false); - const [selectedUser, setSelectedUser] = useState(null); - const [hoveredUser, setHoveredUser] = useState(null); + const [state, dispatch] = useReducer(teamClockReducer, undefined, createInitialTeamClockState); + const { isExpanded, angle, currentTime, isMobile, selectedUser, hoveredUser } = state; useEffect(() => { const checkMobile = () => { - setIsMobile(window.innerWidth < 768); + dispatch({ type: "setMobile", isMobile: window.innerWidth < 768 }); }; - checkMobile(); window.addEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile); @@ -56,41 +123,22 @@ export default function TeamClock({ useEffect(() => { const timer = setInterval(() => { - setCurrentTime(new Date()); + dispatch({ type: "tick", time: new Date() }); }, 1000); return () => clearInterval(timer); }, []); const handleToggle = () => { - setIsExpanded(!isExpanded); + dispatch({ type: "toggle" }); }; const handleUserSelect = (userName: string, timeDifference: string) => { - if (selectedUser === userName) { - setSelectedUser(null); - setAngle(0); - } else { - setSelectedUser(userName); - setAngle(parseInt(timeDifference, 10) * 30); - } + dispatch({ type: "selectUser", userName, timeDifference, users }); }; const handleUserHover = (userName: string | null, timeDifference: string | null) => { - if (userName && timeDifference) { - setHoveredUser(userName); - setAngle(parseInt(timeDifference, 10) * 30); - } else { - setHoveredUser(null); - if (!selectedUser) { - setAngle(0); - } else { - const selectedUserData = users.find((user) => user.name === selectedUser); - if (selectedUserData) { - setAngle(parseInt(selectedUserData.timeDifference, 10) * 30); - } - } - } + dispatch({ type: "hoverUser", userName, timeDifference, users }); }; return ( @@ -182,6 +230,7 @@ export default function TeamClock({ onHover={handleUserHover} isSelected={selectedUser === user.name} isHovered={hoveredUser === user.name} + isMobile={isMobile} currentTime={currentTime} animationDuration={animationDuration} accentColor={accentColor} @@ -215,11 +264,10 @@ function Clock({ textColor, backgroundColor, }: ClockProps) { - const [time, setTime] = useState(null); + const [time, setTime] = useState(() => new Date()); const gradientRef = useRef(null); useEffect(() => { - setTime(new Date()); const interval = setInterval(() => { setTime(new Date()); }, 1000); @@ -241,10 +289,6 @@ function Clock({ } }, [angle, time]); - if (!time) { - return null; - } - const hours = time.getHours(); const minutes = time.getMinutes(); const seconds = time.getSeconds(); @@ -315,6 +359,7 @@ interface ListElementProp { onHover: (name: string | null, timeDifference: string | null) => void; isSelected: boolean; isHovered: boolean; + isMobile: boolean; currentTime: Date; animationDuration: number; accentColor: string; @@ -324,28 +369,16 @@ interface ListElementProp { function ListElement(props: ListElementProp) { const [isHovered, setIsHovered] = useState(false); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - - return () => window.removeEventListener("resize", checkMobile); - }, []); const handleEnter = () => { - if (!isMobile) { + if (!props.isMobile) { setIsHovered(true); props.onHover(props.name, props.timeDifference); } }; const handleLeave = () => { - if (!isMobile) { + if (!props.isMobile) { setIsHovered(false); props.onHover(null, null); } diff --git a/animata/widget/video-chat.tsx b/animata/widget/video-chat.tsx index dec999da..ece6b9d1 100644 --- a/animata/widget/video-chat.tsx +++ b/animata/widget/video-chat.tsx @@ -42,12 +42,16 @@ interface ControlIconStateProps { function SquareDiv({ bgColor = "bg-green-600", children, onClick }: SquareDivProps) { return ( -
{children} -
+ ); } @@ -152,7 +156,7 @@ export default function VideoChat() {
{!minimize && }
-