diff --git a/animata/card/flip-card.stories.tsx b/animata/card/flip-card.stories.tsx index 39757c61..4a7e178e 100644 --- a/animata/card/flip-card.stories.tsx +++ b/animata/card/flip-card.stories.tsx @@ -18,24 +18,54 @@ type Story = StoryObj; export const Primary: Story = { args: { - image: - " https://images.unsplash.com/photo-1525373698358-041e3a460346?w=600&auto=format&fit=crop&q=60&ixlib=rb-4.0.3", - title: "Programming", - subtitle: "What is programming?", - description: - "Computer programming or coding is the composition of sequences of instructions, called programs, that computers can follow to perform tasks.", rotate: "y", }, + render: ({ rotate }) => ( + + + Programming +
Programming
+
+ +
+

What is programming?

+

+ Computer programming or coding is the composition of sequences of instructions, called + programs, that computers can follow to perform tasks. +

+
+
+
+ ), }; export const Secondary: Story = { args: { - image: - "https://images.unsplash.com/photo-1717966313670-a42f6908be92?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3", - title: "Bibek Bhattarai", - subtitle: "Software Engineer", - description: - "I am a full-stack developer with a passion for building beautiful and functional applications.", rotate: "x", }, + render: ({ rotate }) => ( + + + Bibek Bhattarai +
Bibek Bhattarai
+
+ +
+

Software Engineer

+

+ I am a full-stack developer with a passion for building beautiful and functional + applications. +

+
+
+
+ ), }; diff --git a/animata/card/flip-card.tsx b/animata/card/flip-card.tsx index 6c52ed7d..814430c9 100644 --- a/animata/card/flip-card.tsx +++ b/animata/card/flip-card.tsx @@ -1,59 +1,83 @@ +import { type ComponentProps, createContext, use, useMemo } from "react"; + import { cn } from "@/lib/utils"; -interface FlipCardProps extends React.HTMLAttributes { - image: string; - title: string; - description: string; - subtitle?: string; - rotate?: "x" | "y"; +export type FlipCardRotate = "x" | "y"; + +type FlipCardContextValue = { + rotate: FlipCardRotate; +}; + +const FlipCardContext = createContext(null); + +const ROTATION_CLASS = { + x: { + hover: "group-hover/card:rotate-x-180 motion-reduce:group-hover/card:rotate-x-0", + back: "rotate-x-180", + }, + y: { + hover: "group-hover/card:rotate-y-180 motion-reduce:group-hover/card:rotate-y-0", + back: "rotate-y-180", + }, +} as const; + +function useFlipCard() { + const context = use(FlipCardContext); + if (!context) { + throw new Error("FlipCard.Front and FlipCard.Back must be used within ."); + } + return context; } -export default function FlipCard({ - image, - title, - description, - subtitle, - rotate = "y", - className, - ...props -}: FlipCardProps) { - const rotationClass = { - x: ["group-hover/card:rotate-x-180", "rotate-x-180"], - y: ["group-hover/card:rotate-y-180", "rotate-y-180"], - } as const; +type FlipCardRootProps = ComponentProps<"div"> & { + rotate?: FlipCardRotate; +}; + +function FlipCardRoot({ rotate = "y", className, children, ...props }: FlipCardRootProps) { + const value = useMemo(() => ({ rotate }), [rotate]); return ( -
-
- {/* Front */} -
- {title} -
{title}
-
- {/* Back */} + +
-
-

{subtitle}

-

- {description} -

-
+ {children}
-
+ + ); +} + +type FlipCardFaceProps = ComponentProps<"div">; + +function FlipCardFront({ className, ...props }: FlipCardFaceProps) { + useFlipCard(); + + return
; +} + +function FlipCardBack({ className, ...props }: FlipCardFaceProps) { + const { rotate } = useFlipCard(); + + return ( +
); } + +const FlipCard = Object.assign(FlipCardRoot, { + Front: FlipCardFront, + Back: FlipCardBack, +}) as typeof FlipCardRoot & { + Front: typeof FlipCardFront; + Back: typeof FlipCardBack; +}; + +export default FlipCard; +export { FlipCard, FlipCardBack, FlipCardFront, FlipCardRoot }; diff --git a/animata/card/swap-card.tsx b/animata/card/swap-card.tsx index 953238d9..d244dde1 100644 --- a/animata/card/swap-card.tsx +++ b/animata/card/swap-card.tsx @@ -49,12 +49,23 @@ export default function SwapCard({ firstImageClass, )} > - + + + {firstTitle} +
+ {firstTitle} +
+
+ +

+ {firstDescription} +

+
+
- + + + {secondTitle} +
+ {secondTitle} +
+
+ +

+ {secondDescription} +

+
+
diff --git a/animata/list/flipping-cards.stories.tsx b/animata/list/flipping-cards.stories.tsx index ee84040f..3a918d20 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 15374d26..69ea5ded 100644 --- a/animata/list/flipping-cards.tsx +++ b/animata/list/flipping-cards.tsx @@ -1,93 +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; - revealColor: string; -} +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, revealColor }: 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) => { - const revealColor = `hsl(${((index ?? 0) * 47) % 360} 45% 55%)`; +} - 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(${(index * 47) % 360} 45% 55%)`; } + +export default FlippingCards; +export { FlippingCards, FlippingCardsItemWithFaces as FlippingCardsItem }; diff --git a/animata/preloader/split-reveal.stories.tsx b/animata/preloader/split-reveal.stories.tsx index 314bee3b..fd9a9267 100644 --- a/animata/preloader/split-reveal.stories.tsx +++ b/animata/preloader/split-reveal.stories.tsx @@ -35,11 +35,17 @@ export const Primary: Story = {

Page content mounts normally. SplitReveal covers it until images load.

- + + + + + + + + ), args: { - images: SAMPLE_IMAGES, backgroundColor: "#fff", foregroundColor: "#000", revealDuration: 0.85, diff --git a/animata/preloader/split-reveal.tsx b/animata/preloader/split-reveal.tsx index ccf9d653..e5925449 100644 --- a/animata/preloader/split-reveal.tsx +++ b/animata/preloader/split-reveal.tsx @@ -1,570 +1,2 @@ -"use client"; - -import { - type ComponentProps, - type CSSProperties, - createContext, - type ReactNode, - use, - useEffect, - useMemo, - useReducer, - useRef, - useSyncExternalStore, -} from "react"; - -import { cn } from "@/lib/utils"; - -import "./split-reveal.css"; - -type PreloaderPhase = "loading" | "fade-ui" | "reveal" | "done"; - -export interface SplitRevealProgressState { - phase: PreloaderPhase; - progress: number; - loaded: number; - total: number; -} - -interface SplitRevealContextValue extends SplitRevealProgressState { - backgroundColor: string; - foregroundColor: string; - revealDuration: number; - progressFadeMs: number; - zIndex: number; - isActive: boolean; -} - -const SplitRevealContext = createContext(null); - -export function useSplitReveal() { - const context = use(SplitRevealContext); - if (!context) { - throw new Error("SplitReveal primitives must be used within ."); - } - return context; -} - -export interface SplitRevealProps { - images: string[]; - /** Full overlay override — shutters, progress, all of it */ - children?: ReactNode; - /** Swap the center progress UI while keeping default shutters */ - renderProgress?: (state: SplitRevealProgressState) => ReactNode; - overlayClassName?: string; - backgroundColor?: string; - foregroundColor?: string; - revealDuration?: number; - /** Progress UI fade duration before shutters move */ - progressFadeMs?: number; - holdMs?: number; - zIndex?: number; - lockScroll?: boolean; - onComplete?: () => void; -} - -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); -} - -function preloadImages(urls: string[], onProgress: (loaded: number, total: number) => void) { - const total = urls.length; - - if (total === 0) { - onProgress(0, 0); - return Promise.resolve(); - } - - let loaded = 0; - - const preloadOne = (url: string) => - new Promise((resolve) => { - const img = new Image(); - let settled = false; - - const settle = () => { - if (settled) { - return; - } - settled = true; - loaded += 1; - onProgress(loaded, total); - resolve(); - }; - - const ready = () => { - if (typeof img.decode === "function") { - img.decode().then(settle).catch(settle); - return; - } - settle(); - }; - - img.onload = ready; - img.onerror = settle; - img.decoding = "async"; - img.src = url; - - if (img.complete && img.naturalWidth > 0) { - ready(); - } - }); - - return Promise.all(urls.map((url) => preloadOne(url))).then(() => undefined); -} - -function useScrollLock(active: boolean) { - useEffect(() => { - if (!active) { - return; - } - - const html = document.documentElement; - const body = document.body; - const scrollY = window.scrollY; - - const previous = { - htmlOverflow: html.style.overflow, - bodyOverflow: body.style.overflow, - bodyPosition: body.style.position, - bodyTop: body.style.top, - bodyLeft: body.style.left, - bodyRight: body.style.right, - bodyWidth: body.style.width, - bodyTouchAction: body.style.touchAction, - }; - - html.style.overflow = "hidden"; - body.style.overflow = "hidden"; - body.style.position = "fixed"; - body.style.top = `-${scrollY}px`; - body.style.left = "0"; - body.style.right = "0"; - body.style.width = "100%"; - body.style.touchAction = "none"; - - return () => { - html.style.overflow = previous.htmlOverflow; - body.style.overflow = previous.bodyOverflow; - body.style.position = previous.bodyPosition; - body.style.top = previous.bodyTop; - body.style.left = previous.bodyLeft; - body.style.right = previous.bodyRight; - body.style.width = previous.bodyWidth; - body.style.touchAction = previous.bodyTouchAction; - window.scrollTo(0, scrollY); - }; - }, [active]); -} - -function SplitRevealOverlayFrame({ className, children, ...props }: ComponentProps<"div">) { - const { phase, zIndex, revealDuration, progressFadeMs, isActive } = useSplitReveal(); - - if (!isActive) { - return null; - } - - return ( -
- {children} -
- ); -} - -function SplitRevealShutter({ - side, - className, - style, - ...props -}: ComponentProps<"div"> & { side: "top" | "bottom" }) { - const { backgroundColor } = useSplitReveal(); - - return ( -
- ); -} - -function SplitRevealProgressTrack({ - progress, - foregroundColor, - className, -}: { - progress: number; - foregroundColor: string; - className?: string; -}) { - return ( -
-
-
- ); -} - -function SplitRevealProgressCount({ - loaded, - total, - foregroundColor, - className, -}: { - loaded: number; - total: number; - foregroundColor: string; - className?: string; -}) { - return ( -

- {String(loaded).padStart(2, "0")} - / - {String(total).padStart(2, "0")} -

- ); -} - -function SplitRevealProgressSlot({ className, children, ...props }: ComponentProps<"div">) { - return ( -
-
{children}
-
- ); -} - -function SplitRevealProgress({ - className, - children, - ...props -}: ComponentProps<"div"> & { - children?: ReactNode | ((state: SplitRevealProgressState) => ReactNode); -}) { - const { phase, progress, loaded, total, foregroundColor } = useSplitReveal(); - - const content = - typeof children === "function" - ? children({ progress, loaded, total, phase }) - : (children ?? ( - <> - - - - )); - - return ( - - {content} - - ); -} - -function SplitRevealDefaultOverlay({ - renderProgress, -}: { - renderProgress?: (state: SplitRevealProgressState) => ReactNode; -}) { - const state = useSplitReveal(); - - return ( - <> - - - {renderProgress ? ( - - {renderProgress({ - phase: state.phase, - progress: state.progress, - loaded: state.loaded, - total: state.total, - })} - - ) : ( - - )} - - ); -} - -function SplitRevealRoot({ - images, - children, - renderProgress, - overlayClassName, - backgroundColor = "#fff", - foregroundColor = "#000", - revealDuration = 0.85, - progressFadeMs = 280, - holdMs = 240, - zIndex = 100, - lockScroll = true, - onComplete, -}: SplitRevealProps) { - const reduceMotion = usePrefersReducedMotion(); - const onCompleteRef = useRef(onComplete); - const phaseRef = useRef("loading"); - const startPreloadRef = useRef<() => void>(() => {}); - type PreloadSnapshot = { phase: PreloaderPhase; loaded: number; total: number }; - - const preloadReducer = ( - state: PreloadSnapshot, - action: - | { type: "reset"; total: number } - | { type: "progress"; loaded: number; total: number } - | { type: "phase"; phase: PreloaderPhase }, - ): PreloadSnapshot => { - switch (action.type) { - case "reset": - return { phase: "loading", loaded: 0, total: action.total }; - case "progress": - return { ...state, loaded: action.loaded, total: action.total }; - case "phase": - return { ...state, phase: action.phase }; - default: - return state; - } - }; - - const [preload, dispatchPreload] = useReducer(preloadReducer, { - phase: "loading", - loaded: 0, - total: 0, - }); - const { phase, loaded, total } = preload; - - phaseRef.current = phase; - - const uniqueImages = useMemo(() => [...new Set(images.filter(Boolean))], [images]); - - const progress = total === 0 ? 100 : Math.round((loaded / total) * 100); - const isActive = phase !== "done"; - - useEffect(() => { - onCompleteRef.current = onComplete; - }, [onComplete]); - - // Restart preload after bfcache / tab restore via startPreloadRef. - useEffect(() => { - let runId = 0; - let fadeTimer: number | undefined; - let revealTimer: number | undefined; - let doneTimer: number | undefined; - - const clearTimers = () => { - if (fadeTimer !== undefined) { - window.clearTimeout(fadeTimer); - fadeTimer = undefined; - } - if (revealTimer !== undefined) { - window.clearTimeout(revealTimer); - revealTimer = undefined; - } - if (doneTimer !== undefined) { - window.clearTimeout(doneTimer); - doneTimer = undefined; - } - }; - - const finish = (currentRun: number) => { - if (currentRun !== runId) { - return; - } - onCompleteRef.current?.(); - dispatchPreload({ type: "phase", phase: "done" }); - }; - - const startReveal = (currentRun: number) => { - if (reduceMotion) { - finish(currentRun); - return; - } - - dispatchPreload({ type: "phase", phase: "fade-ui" }); - - revealTimer = window.setTimeout(() => { - if (currentRun !== runId) { - return; - } - - dispatchPreload({ type: "phase", phase: "reveal" }); - doneTimer = window.setTimeout(() => finish(currentRun), revealDuration * 1000); - }, progressFadeMs); - }; - - const start = () => { - runId += 1; - const currentRun = runId; - clearTimers(); - - const imageCount = uniqueImages.length; - - dispatchPreload({ type: "reset", total: imageCount }); - - if (imageCount === 0) { - fadeTimer = window.setTimeout(() => startReveal(currentRun), holdMs); - return; - } - - preloadImages(uniqueImages, (nextLoaded, nextTotal) => { - if (currentRun !== runId) { - return; - } - dispatchPreload({ type: "progress", loaded: nextLoaded, total: nextTotal }); - }).then(() => { - if (currentRun !== runId) { - return; - } - - fadeTimer = window.setTimeout(() => startReveal(currentRun), holdMs); - }); - }; - - start(); - startPreloadRef.current = start; - - return () => { - runId += 1; - clearTimers(); - }; - }, [holdMs, progressFadeMs, reduceMotion, revealDuration, uniqueImages]); - - useEffect(() => { - const onPageShow = (event: PageTransitionEvent) => { - if (event.persisted && phaseRef.current !== "done") { - startPreloadRef.current(); - } - }; - - const onResume = () => { - if (phaseRef.current !== "done") { - startPreloadRef.current(); - } - }; - - window.addEventListener("pageshow", onPageShow); - document.addEventListener("resume", onResume); - - return () => { - window.removeEventListener("pageshow", onPageShow); - document.removeEventListener("resume", onResume); - }; - }, []); - - useScrollLock(lockScroll && isActive); - - const contextValue = useMemo( - () => ({ - phase, - progress, - loaded, - total, - backgroundColor, - foregroundColor, - revealDuration, - progressFadeMs, - zIndex, - isActive, - }), - [ - backgroundColor, - foregroundColor, - isActive, - loaded, - phase, - progress, - progressFadeMs, - revealDuration, - total, - zIndex, - ], - ); - - if (!isActive) { - return null; - } - - return ( - - - {children ?? } - - - ); -} - -const SplitReveal = Object.assign(SplitRevealRoot, { - Shutter: SplitRevealShutter, - Progress: SplitRevealProgress, - ProgressTrack: SplitRevealProgressTrack, - ProgressCount: SplitRevealProgressCount, -}); - -export default SplitReveal; -export { - type PreloaderPhase, - preloadImages, - SplitReveal, - SplitRevealOverlayFrame, - SplitRevealProgress, - SplitRevealProgressCount, - SplitRevealProgressSlot, - SplitRevealProgressTrack, - SplitRevealRoot, - SplitRevealShutter, - useScrollLock, -}; +export * from "./split-reveal/index"; +export { default } from "./split-reveal/index"; diff --git a/animata/preloader/split-reveal/context.tsx b/animata/preloader/split-reveal/context.tsx new file mode 100644 index 00000000..4a7a6903 --- /dev/null +++ b/animata/preloader/split-reveal/context.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { createContext, use } from "react"; + +import type { SplitRevealContextValue, SplitRevealInternalContextValue } from "./types"; + +export const SplitRevealContext = createContext(null); +export const SplitRevealInternalContext = createContext( + null, +); + +export function useSplitReveal() { + const context = use(SplitRevealContext); + if (!context) { + throw new Error("SplitReveal primitives must be used within ."); + } + return context; +} + +export function useSplitRevealInternal() { + const context = use(SplitRevealInternalContext); + if (!context) { + throw new Error("SplitReveal.Task and SplitReveal.Images must be used within ."); + } + return context; +} diff --git a/animata/preloader/split-reveal/execute-task.ts b/animata/preloader/split-reveal/execute-task.ts new file mode 100644 index 00000000..6396e0c7 --- /dev/null +++ b/animata/preloader/split-reveal/execute-task.ts @@ -0,0 +1,29 @@ +import type { SplitRevealProgressReporter, SplitRevealTaskDefinition } from "./types"; + +export async function executeTask( + task: SplitRevealTaskDefinition, + report: SplitRevealProgressReporter, + signal: AbortSignal, +) { + const ctx = { report, signal }; + + if (task.run) { + await task.run(ctx); + return; + } + + if (task.generator) { + const iterator = task.generator(ctx); + let result = await iterator.next(); + + while (!result.done) { + if (signal.aborted) { + return; + } + if (result.value) { + report(result.value); + } + result = await iterator.next(); + } + } +} diff --git a/animata/preloader/split-reveal/images.tsx b/animata/preloader/split-reveal/images.tsx new file mode 100644 index 00000000..a5b19026 --- /dev/null +++ b/animata/preloader/split-reveal/images.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useCallback, useMemo } from "react"; + +import { preloadImages } from "./preload-images"; +import { SplitRevealTask } from "./task"; +import type { SplitRevealTaskContext } from "./types"; + +export type SplitRevealImagesProps = { + urls: string[]; +}; + +export function SplitRevealImages({ urls }: SplitRevealImagesProps) { + const uniqueUrls = useMemo(() => [...new Set(urls.filter(Boolean))], [urls]); + + const run = useCallback( + ({ report, signal }: SplitRevealTaskContext) => { + if (signal.aborted) { + return Promise.resolve(); + } + + return preloadImages(uniqueUrls, (next) => { + if (!signal.aborted) { + report(next); + } + }); + }, + [uniqueUrls], + ); + + return ; +} diff --git a/animata/preloader/split-reveal/index.tsx b/animata/preloader/split-reveal/index.tsx new file mode 100644 index 00000000..37c54684 --- /dev/null +++ b/animata/preloader/split-reveal/index.tsx @@ -0,0 +1,53 @@ +"use client"; + +import "../split-reveal.css"; + +import { SplitRevealImages } from "./images"; +import { SplitRevealOverlay } from "./overlay"; +import { SplitRevealProgress } from "./progress"; +import { SplitRevealProgressCount } from "./progress-count"; +import { SplitRevealProgressSlot } from "./progress-slot"; +import { SplitRevealProgressTrack } from "./progress-track"; +import { SplitRevealRoot } from "./root"; +import { SplitRevealShutter } from "./shutter"; +import { SplitRevealTask } from "./task"; + +const SplitReveal = Object.assign(SplitRevealRoot, { + Overlay: SplitRevealOverlay, + Shutter: SplitRevealShutter, + Progress: SplitRevealProgress, + ProgressTrack: SplitRevealProgressTrack, + ProgressCount: SplitRevealProgressCount, + ProgressSlot: SplitRevealProgressSlot, + Task: SplitRevealTask, + Images: SplitRevealImages, +}); + +export default SplitReveal; + +export { useSplitReveal, useSplitRevealInternal } from "./context"; +export { executeTask } from "./execute-task"; +export { SplitRevealImages } from "./images"; +export { SplitRevealOverlay, SplitRevealOverlay as SplitRevealOverlayFrame } from "./overlay"; +export { preloadImages } from "./preload-images"; +export { SplitRevealProgress } from "./progress"; +export { SplitRevealProgressCount } from "./progress-count"; +export { SplitRevealProgressSlot } from "./progress-slot"; +export { SplitRevealProgressTrack } from "./progress-track"; +export { SplitRevealRoot } from "./root"; +export { SplitRevealShutter } from "./shutter"; +export { SplitRevealTask } from "./task"; +export type { + PreloaderPhase, + SplitRevealContextValue, + SplitRevealInternalContextValue, + SplitRevealProgressReporter, + SplitRevealProgressState, + SplitRevealRootProps, + SplitRevealTaskContext, + SplitRevealTaskDefinition, + SplitRevealTaskGenerator, + SplitRevealTaskRun, +} from "./types"; +export { usePrefersReducedMotion } from "./use-prefers-reduced-motion"; +export { useScrollLock } from "./use-scroll-lock"; diff --git a/animata/preloader/split-reveal/overlay.tsx b/animata/preloader/split-reveal/overlay.tsx new file mode 100644 index 00000000..7911cb39 --- /dev/null +++ b/animata/preloader/split-reveal/overlay.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { ComponentProps, CSSProperties } from "react"; + +import { cn } from "@/lib/utils"; + +import { useSplitReveal } from "./context"; + +export function SplitRevealOverlay({ className, children, ...props }: ComponentProps<"div">) { + const { phase, zIndex, revealDuration, progressFadeMs, isActive } = useSplitReveal(); + + if (!isActive) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/animata/preloader/split-reveal/preload-images.ts b/animata/preloader/split-reveal/preload-images.ts new file mode 100644 index 00000000..929d631a --- /dev/null +++ b/animata/preloader/split-reveal/preload-images.ts @@ -0,0 +1,47 @@ +import type { SplitRevealProgressReporter } from "./types"; + +export function preloadImages(urls: string[], onProgress: SplitRevealProgressReporter) { + const total = urls.length; + + if (total === 0) { + onProgress({ loaded: 0, total: 0 }); + return Promise.resolve(); + } + + let loaded = 0; + + const preloadOne = (url: string) => + new Promise((resolve) => { + const img = new Image(); + let settled = false; + + const settle = () => { + if (settled) { + return; + } + settled = true; + loaded += 1; + onProgress({ loaded, total }); + resolve(); + }; + + const ready = () => { + if (typeof img.decode === "function") { + img.decode().then(settle).catch(settle); + return; + } + settle(); + }; + + img.onload = ready; + img.onerror = settle; + img.decoding = "async"; + img.src = url; + + if (img.complete && img.naturalWidth > 0) { + ready(); + } + }); + + return Promise.all(urls.map((url) => preloadOne(url))).then(() => undefined); +} diff --git a/animata/preloader/split-reveal/progress-count.tsx b/animata/preloader/split-reveal/progress-count.tsx new file mode 100644 index 00000000..a4d03208 --- /dev/null +++ b/animata/preloader/split-reveal/progress-count.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils"; + +export function SplitRevealProgressCount({ + loaded, + total, + foregroundColor, + className, +}: { + loaded: number; + total: number; + foregroundColor: string; + className?: string; +}) { + return ( +

+ {String(loaded).padStart(2, "0")} + / + {String(total).padStart(2, "0")} +

+ ); +} diff --git a/animata/preloader/split-reveal/progress-slot.tsx b/animata/preloader/split-reveal/progress-slot.tsx new file mode 100644 index 00000000..f4e9fbed --- /dev/null +++ b/animata/preloader/split-reveal/progress-slot.tsx @@ -0,0 +1,18 @@ +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +export function SplitRevealProgressSlot({ className, children, ...props }: ComponentProps<"div">) { + return ( +
+
{children}
+
+ ); +} diff --git a/animata/preloader/split-reveal/progress-track.tsx b/animata/preloader/split-reveal/progress-track.tsx new file mode 100644 index 00000000..44a9e06b --- /dev/null +++ b/animata/preloader/split-reveal/progress-track.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; + +export function SplitRevealProgressTrack({ + progress, + foregroundColor, + className, +}: { + progress: number; + foregroundColor: string; + className?: string; +}) { + return ( +
+
+
+ ); +} diff --git a/animata/preloader/split-reveal/progress.tsx b/animata/preloader/split-reveal/progress.tsx new file mode 100644 index 00000000..c03f8061 --- /dev/null +++ b/animata/preloader/split-reveal/progress.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { ComponentProps, ReactNode } from "react"; + +import { useSplitReveal } from "./context"; +import { SplitRevealProgressCount } from "./progress-count"; +import { SplitRevealProgressSlot } from "./progress-slot"; +import { SplitRevealProgressTrack } from "./progress-track"; +import type { SplitRevealProgressState } from "./types"; + +export function SplitRevealProgress({ + className, + children, + ...props +}: Omit, "children"> & { + children?: ReactNode | ((state: SplitRevealProgressState) => ReactNode); +}) { + const { phase, progress, loaded, total, foregroundColor } = useSplitReveal(); + + const content = + typeof children === "function" + ? children({ progress, loaded, total, phase }) + : (children ?? ( + <> + + + + )); + + return ( + + {content} + + ); +} diff --git a/animata/preloader/split-reveal/root.tsx b/animata/preloader/split-reveal/root.tsx new file mode 100644 index 00000000..170a8b18 --- /dev/null +++ b/animata/preloader/split-reveal/root.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; + +import { SplitRevealContext, SplitRevealInternalContext } from "./context"; +import { executeTask } from "./execute-task"; +import type { + PreloaderPhase, + SplitRevealInternalContextValue, + SplitRevealProgressReporter, + SplitRevealRootProps, + SplitRevealTaskDefinition, +} from "./types"; +import { usePrefersReducedMotion } from "./use-prefers-reduced-motion"; +import { useScrollLock } from "./use-scroll-lock"; + +export function SplitRevealRoot({ + children, + progress: controlledProgress, + ready: controlledReady, + backgroundColor = "#fff", + foregroundColor = "#000", + revealDuration = 0.85, + progressFadeMs = 280, + holdMs = 240, + zIndex = 100, + lockScroll = true, + onComplete, +}: SplitRevealRootProps) { + const reduceMotion = usePrefersReducedMotion(); + const onCompleteRef = useRef(onComplete); + const phaseRef = useRef("loading"); + const tasksRef = useRef>(new Map()); + const [bootstrapEpoch, setBootstrapEpoch] = useState(0); + + const requestBootstrap = useCallback(() => { + if (phaseRef.current === "done") { + return; + } + setBootstrapEpoch((epoch) => epoch + 1); + }, []); + + type PreloadSnapshot = { phase: PreloaderPhase; loaded: number; total: number }; + + const preloadReducer = ( + state: PreloadSnapshot, + action: + | { type: "reset"; total: number } + | { type: "progress"; loaded: number; total: number } + | { type: "phase"; phase: PreloaderPhase }, + ): PreloadSnapshot => { + switch (action.type) { + case "reset": + if (state.phase === "done") { + return state; + } + return { phase: "loading", loaded: 0, total: action.total }; + case "progress": + if (state.phase === "done") { + return state; + } + return { ...state, loaded: action.loaded, total: action.total }; + case "phase": + return { ...state, phase: action.phase }; + default: + return state; + } + }; + + const [preload, dispatchPreload] = useReducer(preloadReducer, { + phase: "loading", + loaded: 0, + total: 0, + }); + const { phase, loaded, total } = preload; + + phaseRef.current = phase; + + const progressPercent = total === 0 ? 100 : Math.round((loaded / total) * 100); + const isActive = phase !== "done"; + const readyControlled = controlledReady !== undefined; + + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + + useEffect(() => { + if (controlledProgress === undefined) { + return; + } + + if (phaseRef.current === "done") { + return; + } + + dispatchPreload({ + type: "progress", + loaded: controlledProgress.loaded, + total: controlledProgress.total, + }); + }, [controlledProgress]); + + const registerTask = useCallback( + (id: string, task: SplitRevealTaskDefinition) => { + const isNew = !tasksRef.current.has(id); + tasksRef.current.set(id, task); + + if (isNew && phaseRef.current === "loading") { + requestBootstrap(); + } + + return () => { + tasksRef.current.delete(id); + }; + }, + [requestBootstrap], + ); + + const bootstrapRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (phaseRef.current === "done") { + return; + } + + let runId = 0; + let fadeTimer: number | undefined; + let revealTimer: number | undefined; + let doneTimer: number | undefined; + let abortController: AbortController | undefined; + let tasksDone = false; + + const clearTimers = () => { + if (fadeTimer !== undefined) { + window.clearTimeout(fadeTimer); + fadeTimer = undefined; + } + if (revealTimer !== undefined) { + window.clearTimeout(revealTimer); + revealTimer = undefined; + } + if (doneTimer !== undefined) { + window.clearTimeout(doneTimer); + doneTimer = undefined; + } + }; + + const finish = (currentRun: number) => { + if (currentRun !== runId) { + return; + } + onCompleteRef.current?.(); + dispatchPreload({ type: "phase", phase: "done" }); + }; + + const startReveal = (currentRun: number) => { + if (phaseRef.current !== "loading") { + return; + } + + if (reduceMotion) { + finish(currentRun); + return; + } + + dispatchPreload({ type: "phase", phase: "fade-ui" }); + + revealTimer = window.setTimeout(() => { + if (currentRun !== runId) { + return; + } + + dispatchPreload({ type: "phase", phase: "reveal" }); + doneTimer = window.setTimeout(() => finish(currentRun), revealDuration * 1000); + }, progressFadeMs); + }; + + const scheduleReveal = (currentRun: number) => { + if (fadeTimer !== undefined) { + return; + } + fadeTimer = window.setTimeout(() => startReveal(currentRun), holdMs); + }; + + const maybeReveal = (currentRun: number) => { + const hasTasks = tasksRef.current.size > 0; + const tasksReady = !hasTasks || tasksDone; + const readyOk = !readyControlled || controlledReady === true; + + if (!tasksReady || !readyOk) { + return; + } + + if (!hasTasks && !readyControlled) { + return; + } + + scheduleReveal(currentRun); + }; + + const bootstrap = () => { + runId += 1; + const currentRun = runId; + clearTimers(); + abortController?.abort(); + abortController = new AbortController(); + tasksDone = false; + + const tasks = [...tasksRef.current.values()]; + const hasTasks = tasks.length > 0; + + if (!hasTasks) { + const nextTotal = controlledProgress?.total ?? 0; + const nextLoaded = controlledProgress?.loaded ?? 0; + dispatchPreload({ type: "reset", total: nextTotal }); + if (controlledProgress !== undefined) { + dispatchPreload({ + type: "progress", + loaded: nextLoaded, + total: nextTotal, + }); + } + + maybeReveal(currentRun); + return; + } + + dispatchPreload({ type: "reset", total: 0 }); + + const report: SplitRevealProgressReporter = (next) => { + if (currentRun !== runId || abortController?.signal.aborted) { + return; + } + dispatchPreload({ type: "progress", loaded: next.loaded, total: next.total }); + }; + + Promise.all( + tasks.map((task) => + executeTask(task, report, abortController?.signal ?? new AbortSignal()), + ), + ).then(() => { + if (currentRun !== runId || abortController?.signal.aborted) { + return; + } + + tasksDone = true; + maybeReveal(currentRun); + }); + }; + + bootstrapRef.current = bootstrap; + bootstrap(); + + return () => { + runId += 1; + clearTimers(); + abortController?.abort(); + bootstrapRef.current = null; + }; + }, [ + controlledProgress, + controlledReady, + holdMs, + progressFadeMs, + readyControlled, + reduceMotion, + revealDuration, + ]); + + useEffect(() => { + if (bootstrapEpoch === 0) { + return; + } + + bootstrapRef.current?.(); + }, [bootstrapEpoch]); + + useEffect(() => { + const restartFromCache = () => { + if (phaseRef.current === "done") { + return; + } + + dispatchPreload({ type: "reset", total: 0 }); + requestBootstrap(); + }; + + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + restartFromCache(); + } + }; + + const onResume = () => { + restartFromCache(); + }; + + window.addEventListener("pageshow", onPageShow); + document.addEventListener("resume", onResume); + + return () => { + window.removeEventListener("pageshow", onPageShow); + document.removeEventListener("resume", onResume); + }; + }, [requestBootstrap]); + + useScrollLock(lockScroll && isActive); + + const contextValue = useMemo( + () => ({ + phase, + progress: progressPercent, + loaded, + total, + backgroundColor, + foregroundColor, + revealDuration, + progressFadeMs, + zIndex, + isActive, + }), + [ + backgroundColor, + foregroundColor, + isActive, + loaded, + phase, + progressPercent, + progressFadeMs, + revealDuration, + total, + zIndex, + ], + ); + + const internalValue = useMemo( + () => ({ registerTask }), + [registerTask], + ); + + if (!isActive) { + return null; + } + + return ( + + {children} + + ); +} diff --git a/animata/preloader/split-reveal/shutter.tsx b/animata/preloader/split-reveal/shutter.tsx new file mode 100644 index 00000000..0f4db62c --- /dev/null +++ b/animata/preloader/split-reveal/shutter.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +import { useSplitReveal } from "./context"; + +export function SplitRevealShutter({ + side, + className, + style, + ...props +}: ComponentProps<"div"> & { side: "top" | "bottom" }) { + const { backgroundColor } = useSplitReveal(); + + return ( +
+ ); +} diff --git a/animata/preloader/split-reveal/task.tsx b/animata/preloader/split-reveal/task.tsx new file mode 100644 index 00000000..1fd93491 --- /dev/null +++ b/animata/preloader/split-reveal/task.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useId, useRef } from "react"; + +import { useSplitRevealInternal } from "./context"; +import type { + SplitRevealTaskDefinition, + SplitRevealTaskGenerator, + SplitRevealTaskRun, +} from "./types"; + +export type SplitRevealTaskProps = { + run?: SplitRevealTaskRun; + generator?: SplitRevealTaskGenerator; +}; + +export function SplitRevealTask({ run, generator }: SplitRevealTaskProps) { + const id = useId(); + const { registerTask } = useSplitRevealInternal(); + const runRef = useRef(run); + const generatorRef = useRef(generator); + runRef.current = run; + generatorRef.current = generator; + + useEffect(() => { + const task: SplitRevealTaskDefinition = { + run: runRef.current ? (ctx) => runRef.current?.(ctx) ?? Promise.resolve() : undefined, + generator: generatorRef.current, + }; + + if (!task.run && !task.generator) { + return; + } + + return registerTask(id, task); + }, [id, registerTask]); + + return null; +} diff --git a/animata/preloader/split-reveal/types.ts b/animata/preloader/split-reveal/types.ts new file mode 100644 index 00000000..9d717722 --- /dev/null +++ b/animata/preloader/split-reveal/types.ts @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; + +export type PreloaderPhase = "loading" | "fade-ui" | "reveal" | "done"; + +export interface SplitRevealProgressState { + phase: PreloaderPhase; + progress: number; + loaded: number; + total: number; +} + +export type SplitRevealProgress = { + loaded: number; + total: number; +}; + +export type SplitRevealProgressReporter = (progress: SplitRevealProgress) => void; + +export type SplitRevealTaskContext = { + report: SplitRevealProgressReporter; + signal: AbortSignal; +}; + +export type SplitRevealTaskRun = (ctx: SplitRevealTaskContext) => Promise; + +export type SplitRevealTaskGenerator = ( + ctx: SplitRevealTaskContext, +) => AsyncGenerator; + +export type SplitRevealTaskDefinition = { + run?: SplitRevealTaskRun; + generator?: SplitRevealTaskGenerator; +}; + +export interface SplitRevealContextValue extends SplitRevealProgressState { + backgroundColor: string; + foregroundColor: string; + revealDuration: number; + progressFadeMs: number; + zIndex: number; + isActive: boolean; +} + +export interface SplitRevealInternalContextValue { + registerTask: (id: string, task: SplitRevealTaskDefinition) => () => void; +} + +export interface SplitRevealRootProps { + children?: ReactNode; + /** Controlled progress for external loaders, generators, or app boot state */ + progress?: SplitRevealProgress; + /** When true, starts the reveal sequence. Use without Task/Images for full external control */ + ready?: boolean; + backgroundColor?: string; + foregroundColor?: string; + revealDuration?: number; + /** Progress UI fade duration before shutters move */ + progressFadeMs?: number; + holdMs?: number; + zIndex?: number; + lockScroll?: boolean; + onComplete?: () => void; +} diff --git a/animata/preloader/split-reveal/use-prefers-reduced-motion.ts b/animata/preloader/split-reveal/use-prefers-reduced-motion.ts new file mode 100644 index 00000000..30e59a2e --- /dev/null +++ b/animata/preloader/split-reveal/use-prefers-reduced-motion.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +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; +} + +export function usePrefersReducedMotion() { + return useSyncExternalStore(subscribeReducedMotion, getReducedMotionSnapshot, () => false); +} diff --git a/animata/preloader/split-reveal/use-scroll-lock.ts b/animata/preloader/split-reveal/use-scroll-lock.ts new file mode 100644 index 00000000..8f509cbe --- /dev/null +++ b/animata/preloader/split-reveal/use-scroll-lock.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; + +export function useScrollLock(active: boolean) { + useEffect(() => { + if (!active) { + return; + } + + const html = document.documentElement; + const body = document.body; + const scrollY = window.scrollY; + + const previous = { + htmlOverflow: html.style.overflow, + bodyOverflow: body.style.overflow, + bodyPosition: body.style.position, + bodyTop: body.style.top, + bodyLeft: body.style.left, + bodyRight: body.style.right, + bodyWidth: body.style.width, + bodyTouchAction: body.style.touchAction, + }; + + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; + body.style.position = "fixed"; + body.style.top = `-${scrollY}px`; + body.style.left = "0"; + body.style.right = "0"; + body.style.width = "100%"; + body.style.touchAction = "none"; + + return () => { + html.style.overflow = previous.htmlOverflow; + body.style.overflow = previous.bodyOverflow; + body.style.position = previous.bodyPosition; + body.style.top = previous.bodyTop; + body.style.left = previous.bodyLeft; + body.style.right = previous.bodyRight; + body.style.width = previous.bodyWidth; + body.style.touchAction = previous.bodyTouchAction; + window.scrollTo(0, scrollY); + }; + }, [active]); +} diff --git a/app/demo/generated/demo-sources.ts b/app/demo/generated/demo-sources.ts index b0eb6754..e5f845a3 100644 --- a/app/demo/generated/demo-sources.ts +++ b/app/demo/generated/demo-sources.ts @@ -2918,25 +2918,19 @@ export default function PhotographerPortfolio() { setPreloaderDone(true)} - renderProgress={({ loaded, total, progress }) => ( - <> - -

- Loading frames - · - {String(loaded).padStart(2, "0")} - / - {String(total).padStart(2, "0")} -

- - )} - /> + > + + + + + + +
@@ -3308,25 +3302,19 @@ export default function PhotographerPortfolio() { </section> <SplitReveal - images={PRELOAD_IMAGES} backgroundColor={CANVAS} foregroundColor={INK} zIndex={120} lockScroll onComplete={() => setPreloaderDone(true)} - renderProgress={({ loaded, total, progress }) => ( - <> - <SplitReveal.ProgressTrack progress={progress} foregroundColor={INK} /> - <p className="mt-3 text-center text-[11px] font-medium uppercase tracking-[0.12em] text-black/45"> - Loading frames - <span className="px-1.5 text-black/20">·</span> - {String(loaded).padStart(2, "0")} - <span className="text-black/20">/</span> - {String(total).padStart(2, "0")} - </p> - </> - )} - /> + > + <SplitReveal.Images urls={PRELOAD_IMAGES} /> + <SplitReveal.Overlay> + <SplitReveal.Shutter side="top" /> + <SplitReveal.Shutter side="bottom" /> + <SplitReveal.Progress /> + </SplitReveal.Overlay> + </SplitReveal> <PhotographerPortfolioNotes /> </> @@ -3698,25 +3686,19 @@ export default function PhotographerPortfolio() { </section> <SplitReveal - images={PRELOAD_IMAGES} backgroundColor={CANVAS} foregroundColor={INK} zIndex={120} lockScroll onComplete={() => setPreloaderDone(true)} - renderProgress={({ loaded, total, progress }) => ( - <> - <SplitReveal.ProgressTrack progress={progress} foregroundColor={INK} /> - <p className="mt-3 text-center text-[11px] font-medium uppercase tracking-[0.12em] text-black/45"> - Loading frames - <span className="px-1.5 text-black/20">·</span> - {String(loaded).padStart(2, "0")} - <span className="text-black/20">/</span> - {String(total).padStart(2, "0")} - </p> - </> - )} - /> + > + <SplitReveal.Images urls={PRELOAD_IMAGES} /> + <SplitReveal.Overlay> + <SplitReveal.Shutter side="top" /> + <SplitReveal.Shutter side="bottom" /> + <SplitReveal.Progress /> + </SplitReveal.Overlay> + </SplitReveal> <PhotographerPortfolioNotes /> </> diff --git a/app/demo/library/hero/photographer-portfolio-notes.tsx b/app/demo/library/hero/photographer-portfolio-notes.tsx index 8e9ff783..820682ac 100644 --- a/app/demo/library/hero/photographer-portfolio-notes.tsx +++ b/app/demo/library/hero/photographer-portfolio-notes.tsx @@ -30,19 +30,14 @@ const LAYOUT_SNIPPET = `{/* mobile: column stack; desktop: 2:3 row, both pinned
`; -const PRELOADER_SNIPPET = `
{/* your page */}
- - setPreloaderDone(true)} - renderProgress={({ loaded, total, progress }) => ( - <> - -

Loading frames · {loaded}/{total}

- - )} -/>`; +const PRELOADER_SNIPPET = ` setPreloaderDone(true)}> + + + + + + +`; export function PhotographerPortfolioNotes() { const sources = DEMO_SOURCES[DEMO_KEY] ?? []; @@ -79,9 +74,10 @@ export function PhotographerPortfolioNotes() {

- SplitReveal sits next to the page as a fixed overlay. Pass{" "} - images, wire onComplete, done. We skipped the{" "} - .Content wrapper on purpose; the real layout mounts normally underneath. + SplitReveal sits next to the page as a fixed overlay. Compose{" "} + Images, Overlay, shutters, and the default{" "} + Progress track on the center seam. Wire onComplete when the + reveal finishes.

CardStack advances the portfolio. Autoplay only starts after the preloader diff --git a/app/demo/library/hero/photographer-portfolio.tsx b/app/demo/library/hero/photographer-portfolio.tsx index 9b687086..e1d8e3ca 100644 --- a/app/demo/library/hero/photographer-portfolio.tsx +++ b/app/demo/library/hero/photographer-portfolio.tsx @@ -363,25 +363,19 @@ export default function PhotographerPortfolio() { setPreloaderDone(true)} - renderProgress={({ loaded, total, progress }) => ( - <> - -

- Loading frames - · - {String(loaded).padStart(2, "0")} - / - {String(total).padStart(2, "0")} -

- - )} - /> + > + + + + + + +
diff --git a/content/docs/card/flip-card.mdx b/content/docs/card/flip-card.mdx index 4f0a692b..a9097ac8 100644 --- a/content/docs/card/flip-card.mdx +++ b/content/docs/card/flip-card.mdx @@ -34,6 +34,31 @@ Open the newly created file and paste the following code: +## Usage + +`FlipCard` is bare bones: perspective, hover flip, and front/back faces. You bring the markup and styling. + +Pass `rotate="x"` or `rotate="y"` (default) on the root. Style each face with `className` on `FlipCard.Front` and `FlipCard.Back`. + +```tsx +import FlipCard from "@/animata/card/flip-card"; + + + + Programming +
Programming
+
+ +

What is programming?

+

Your copy here.

+
+
+``` + +## Changelog + +- **2026-06** — Replaced fixed `image` / `title` / `description` props with composable `FlipCard.Front` and `FlipCard.Back` faces. + ## Credits Built by [Bibek Bhattarai](https://github.com/morphhyy) diff --git a/content/docs/card/index.mdx b/content/docs/card/index.mdx index c5d26ce4..64636feb 100644 --- a/content/docs/card/index.mdx +++ b/content/docs/card/index.mdx @@ -10,6 +10,7 @@ author: sudhashrestha | Date | Component | Change | | --- | --- | --- | +| 2026-06 | [Flip Card](/docs/card/flip-card) | Composable `FlipCard.Front` / `FlipCard.Back` API | | 2026-06 | [Card Stack](/docs/card/card-stack) | Bare-bones stack API; profile UI in Storybook demo; `Trigger` `full` overlay | | 2026-06 | [Card Spread](/docs/card/card-spread) | Throw-on-click easing; Safari spread jitter fix | | 2026-06 | [Collab Card](/docs/card/collab-card) | New — multiplayer presence card with animated cursors | diff --git a/content/docs/changelog/2026-06.mdx b/content/docs/changelog/2026-06.mdx index 128a1da7..94e5ca53 100644 --- a/content/docs/changelog/2026-06.mdx +++ b/content/docs/changelog/2026-06.mdx @@ -87,6 +87,18 @@ Ran a site-wide react-doctor remediation pass and cleared the scan to zero error

Card Stack

Trimmed the component to bare-bones stack mechanics (`CardStack`, `Viewport`, `List`, `Card`, `Trigger`). Profile card UI — masks, header, media, metrics — moved to the Storybook demo composition. Added `full` on `Trigger` for click-anywhere overlay vs discrete button.

+ +

Flip Card

+

Replaced fixed image/title/description props with composable `FlipCard.Front` and `FlipCard.Back` faces. Hover flip along X or Y stays on the root.

+
+ +

Flipping Cards

+

Replaced the `list` prop and inline flip markup with `FlippingCards`, `Item`, `Item.Front`, and `Item.Back` — grid layout on the list, flip mechanics delegated to Flip Card.

+
+ +

Split Reveal

+

Moved image preloading to opt-in `SplitReveal.Images`. Added `SplitReveal.Task` for custom async or generator progress, plus `ready` and `progress` props for external boot control. Shutters and progress UI compose under `SplitReveal.Overlay`.

+
## Documentation diff --git a/content/docs/list/flipping-cards.mdx b/content/docs/list/flipping-cards.mdx index 898181e3..99b2ad3d 100644 --- a/content/docs/list/flipping-cards.mdx +++ b/content/docs/list/flipping-cards.mdx @@ -18,11 +18,7 @@ author: morphhyy Install dependencies -This uses [Marquee](/docs/container/marquee) for the text. Install it by following the instructions [here](/docs/container/marquee#installation). - -```bash -npm install motion -``` +This uses [Flip Card](/docs/card/flip-card) for the hover flip and [Marquee](/docs/container/marquee) for the back-face text. Install both by following their installation steps. Run the following command @@ -42,6 +38,38 @@ Open the newly created file and paste the following code: +## Usage + +`FlippingCards` is bare bones: a responsive grid wrapper and sized flip items. Each `FlippingCards.Item` wraps [Flip Card](/docs/card/flip-card) mechanics — use `Item.Front` and `Item.Back` for face markup. + +The Storybook demo shows a typography grid with per-card accent colors via `getFlippingCardsAccent(index)`. + +```tsx +import FlippingCards, { getFlippingCardsAccent } from "@/animata/list/flipping-cards"; + + + {items.map((item, index) => ( + + + {item.label} + + +

{item.detail}

+
+
+ ))} +
+``` + +Override grid or card size with `className` on `FlippingCards` or each `Item`. + +## Changelog + +- **2026-06** — Replaced the `list` prop and inline 3D flip with composable `FlippingCards.Item`, `Item.Front`, and `Item.Back` built on Flip Card. + ## Credits Built by [Bibek Bhattarai](https://github.com/morphhyy) diff --git a/content/docs/list/index.mdx b/content/docs/list/index.mdx index e789d0a1..b26921de 100644 --- a/content/docs/list/index.mdx +++ b/content/docs/list/index.mdx @@ -5,3 +5,9 @@ author: sudhashrestha --- + +## Recent changes + +| Date | Component | Change | +| --- | --- | --- | +| 2026-06 | [Flipping Cards](/docs/list/flipping-cards) | Composable grid + `Item.Front` / `Item.Back` on Flip Card | diff --git a/content/docs/preloader/index.mdx b/content/docs/preloader/index.mdx index 79bbd679..27b578ed 100644 --- a/content/docs/preloader/index.mdx +++ b/content/docs/preloader/index.mdx @@ -5,3 +5,9 @@ author: sudhashrestha --- + +## Recent changes + +| Date | Component | Change | +| --- | --- | --- | +| 2026-06 | [Split Reveal](/docs/preloader/split-reveal) | Composable `Images`, `Task`, `Overlay`; modular source under `split-reveal/` | diff --git a/content/docs/preloader/split-reveal.mdx b/content/docs/preloader/split-reveal.mdx index 716f28e5..0a85ab14 100644 --- a/content/docs/preloader/split-reveal.mdx +++ b/content/docs/preloader/split-reveal.mdx @@ -38,35 +38,107 @@ mkdir -p components/animata/preloader && touch components/animata/preloader/spli ## Usage -Place it next to your page. No wrapper required. +`SplitReveal` is bare bones: phase timing, scroll lock, and shutter animation. Loading work and overlay UI are opt-in via composition. + +Place it next to your page. Compose a loader task, the overlay, and whichever UI pieces you need. ```tsx
{/* your layout */}
+ setReady(true)}> + + + + + + + +``` + +### Custom async work + +Use `SplitReveal.Task` for any async boot sequence — fetch calls, WASM init, or an async generator that yields progress. + +```tsx + setReady(true)}> + { + report({ loaded: 0, total: 3 }); + await warmCache(signal); + report({ loaded: 1, total: 3 }); + await loadFonts(signal); + report({ loaded: 2, total: 3 }); + await hydrateStore(signal); + report({ loaded: 3, total: 3 }); + }} + /> + {/* shutters only, or nothing */} + +``` + +Async generators work too — yield `{ loaded, total }` updates from `SplitReveal.Task`: + +```tsx + +``` + +Or drive everything externally with `progress` and `ready` — no `Task` or `Images` required. + +```tsx = 100} + progress={{ loaded: bootProgress, total: 100 }} onComplete={() => setReady(true)} - renderProgress={({ loaded, total }) => ( -

- {loaded}/{total} -

- )} -/> +> + + + + +
``` -Use `renderProgress` to swap the center counter while keeping the default shutters. Pass `children` to replace the whole overlay. +Custom center UI: pass a render function to `SplitReveal.Progress` or compose `ProgressTrack` / `ProgressCount` / `ProgressSlot` yourself inside `Overlay`. + +### Pick only what you need -After the last image decodes, the loader waits (`holdMs`), fades the progress UI (`progressFadeMs`), then splits the shutters. Timing runs on CSS keyframes with a custom cubic-bezier. `revealDuration` defaults to `0.85`s. +Source lives in co-located modules under `split-reveal/`. Import the compound API from the entry file, or pull individual pieces for smaller bundles: + +```tsx +import SplitReveal from "@/animata/preloader/split-reveal"; + +import { SplitRevealRoot } from "@/animata/preloader/split-reveal/root"; +import { SplitRevealOverlay } from "@/animata/preloader/split-reveal/overlay"; +import { preloadImages } from "@/animata/preloader/split-reveal/preload-images"; +``` + +Skip `Images` if you use `Task` or external `ready` / `progress`. Skip `Progress*` if you only want shutters. The shadcn registry installs every module the entry re-exports — copy individual files manually if you want a minimal subset. + +After loading completes, the loader waits (`holdMs`), fades the progress UI (`progressFadeMs`), then splits the shutters. Timing runs on CSS keyframes with a custom cubic-bezier. `revealDuration` defaults to `0.85`s. ## How it works -The component preloads every URL in `images` with `Image()` plus `decode()` when available. Scroll locks while the overlay is active and releases on `done`. +`SplitReveal.Images` preloads URLs with `Image()` plus `decode()` when available. `SplitReveal.Task` accepts a `run` promise or `generator` async iterator that reports `{ loaded, total }`. -Phases go `loading` → `fade-ui` → `reveal` → `done`. Shutters are plain divs animated with `@keyframes`; no Motion dependency. `useSplitReveal()` exposes phase, counts, and colors if you build a custom overlay in `children`. +Scroll locks while the overlay is active and releases on `done`. + +Phases go `loading` → `fade-ui` → `reveal` → `done`. Shutters are plain divs animated with `@keyframes`; no Motion dependency. `useSplitReveal()` exposes phase, counts, and colors for custom overlay markup. Restoring a tab from bfcache gets a fresh boot id so the preloader does not stick on a stale `0/0` count. +## Changelog + +- **2026-06** — Moved image preloading to opt-in `SplitReveal.Images`. Added `SplitReveal.Task` for custom async/generator progress and `ready` / `progress` props for external control. Shutters and progress UI are composable under `SplitReveal.Overlay`. Split source into co-located modules under `split-reveal/` for tree-shaking and selective copy. + ## Credits Built by [hari](https://github.com/hari) diff --git a/scripts/build-registry.js b/scripts/build-registry.js index 60ca574b..792214fc 100644 --- a/scripts/build-registry.js +++ b/scripts/build-registry.js @@ -212,6 +212,28 @@ function registryFileEntry(ref, content) { }; } +function resolveRelativeImport(dir, spec) { + const base = path.posix.normalize(path.posix.join(dir, spec)); + if (path.extname(base)) { + return readIfExists(path.join(ROOT, base)) ? base : null; + } + for (const ext of [".tsx", ".ts"]) { + const candidate = `${base}${ext}`; + if (readIfExists(path.join(ROOT, candidate))) { + return candidate; + } + } + const indexTsx = path.posix.join(base, "index.tsx"); + if (readIfExists(path.join(ROOT, indexTsx))) { + return indexTsx; + } + const indexTs = path.posix.join(base, "index.ts"); + if (readIfExists(path.join(ROOT, indexTs))) { + return indexTs; + } + return null; +} + function addBundledSourceFile(ref, files, bundledRefs, queue) { if (bundledRefs.has(ref)) return false; const abs = path.join(ROOT, ref); @@ -223,9 +245,18 @@ function addBundledSourceFile(ref, files, bundledRefs, queue) { const dir = path.posix.dirname(ref); for (const spec of parseImports(content)) { - if (!spec.startsWith("./") || !spec.endsWith(".css")) continue; - const cssRef = path.posix.normalize(path.posix.join(dir, spec)); - addBundledSourceFile(cssRef, files, bundledRefs, queue); + if (!spec.startsWith("./")) continue; + + if (spec.endsWith(".css")) { + const cssRef = path.posix.normalize(path.posix.join(dir, spec)); + addBundledSourceFile(cssRef, files, bundledRefs, queue); + continue; + } + + const coLocatedRef = resolveRelativeImport(dir, spec); + if (coLocatedRef) { + addBundledSourceFile(coLocatedRef, files, bundledRefs, queue); + } } return true; @@ -233,7 +264,8 @@ function addBundledSourceFile(ref, files, bundledRefs, queue) { function parseImports(source) { const imports = []; - const re = /import\s+(?:[^'"]*?\s+from\s+)?["']([^"']+)["']/g; + const re = + /(?:import\s+(?:[^'"]*?\s+from\s+)?|export\s+(?:\*|\{[^}]*\})\s+from\s+)["']([^"']+)["']/g; for (const m of source.matchAll(re)) { imports.push(m[1]); }