Codrops 원본

Building an Infinite Marquee Along an SVG Path with React & Motion

섹션 · MIT

섹션 더 보기
ORIGINAL PREVIEW
Building an Infinite Marquee Along an SVG Path with React & Motion 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

230개 파일

수집한 원본 소스와 실행 안내를 함께 제공합니다.

next.config.js
파일 저장

const withMDX = require("@next/mdx")()

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"],
  async redirects() {
    return [
      {
        source: "/docs",
        destination: "/docs/introduction",
        permanent: true,
      },
    ]
  },
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.unsplash.com",
        port: "",
        pathname: "/**",
      },
      {
        protocol: "https",
        hostname: "plus.unsplash.com",
        port: "",
        pathname: "/**",
      },
      {
        protocol: "https",
        port: "",
        hostname: "musicbrainz.org",
        pathname: "/**",
      },
      {
        protocol: "https",
        hostname: "fancycomponents.b-cdn.net",
        port: "",
        pathname: "/**",
      }
    ],
  },
}

module.exports = withMDX(nextConfig)
postcss.config.mjs
파일 저장

/** @type {import('postcss-load-config').Config} */
const config = {
  plugins: {
    '@tailwindcss/postcss': {},
  },
}

export default config
함께 쓰는 파일 228개 보기
prettier.config.cjs
파일 저장

/** @type {import('prettier').Config} */
module.exports = {
  endOfLine: "lf",
  semi: false,
  singleQuote: false,
  tabWidth: 2,
  trailingComma: "es5",
  importOrder: [
    "^(react/(.*)$)|^(react$)",
    "^(next/(.*)$)|^(next$)",
    "<THIRD_PARTY_MODULES>",
    "",
    "^@workspace/(.*)$",
    "",
    "^types$",
    "^@/types/(.*)$",
    "^@/config/(.*)$",
    "^@/lib/(.*)$",
    "^@/hooks/(.*)$",
    "^@/components/ui/(.*)$",
    "^@/components/(.*)$",
    "^@/fancy/(.*)$",
    "^@/styles/(.*)$",
    "^@/app/(.*)$",
    "",
    "^[./]",
  ],
  importOrderSeparation: false,
  importOrderSortSpecifiers: true,
  importOrderBuiltinModulesToTop: true,
  importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
  importOrderMergeDuplicateImports: true,
  importOrderCombineTypeAndValueImports: true,
  plugins: ["@ianvs/prettier-plugin-sort-imports"],
}
src/app/api/[...slug]/route.ts
파일 저장

import { NextRequest, NextResponse } from "next/server"
import fs from "node:fs"
import path from "node:path"

export async function GET(
  request: NextRequest,
  props: { params: Promise<{ slug: string[] } | undefined> }
) {
  const params = await props.params;
  try {
    // Check if params and slug exist
    if (!params?.slug) {
      return new NextResponse("Not Found", { status: 404 })
    }
    
    // Check if the request is for a markdown file
    const lastSegment = params.slug[params.slug.length - 1]
    if (!lastSegment.endsWith(".md")) {
      return new NextResponse("Not Found", { status: 404 })
    }
    
    // Remove .md extension to get the actual slug
    const actualSlug = [...params.slug]
    actualSlug[actualSlug.length - 1] = lastSegment.replace(".md", "")
    
    // Construct path to pre-generated markdown file
    const markdownPath = path.join(
      process.cwd(), 
      "public", 
      "docs", 
      ...actualSlug
    ) + ".md"
    
    // Check if file exists
    if (!fs.existsSync(markdownPath)) {
      return new NextResponse("Documentation not found", { status: 404 })
    }
    
    // Read and serve the pre-generated markdown
    const markdown = fs.readFileSync(markdownPath, "utf8")
    
    return new NextResponse(markdown, {
      status: 200,
      headers: {
        "Content-Type": "text/markdown; charset=utf-8",
        "Cache-Control": "public, max-age=31536000, immutable",
        "Content-Disposition": `inline; filename="${actualSlug.join("-")}.md"`
      }
    })
    
  } catch (error) {
    console.error("Error serving markdown:", error)
    return new NextResponse("Internal Server Error", { status: 500 })
  }
}
src/app/api/revalidate/route.ts
파일 저장

import { revalidateTag } from "next/cache"
import { NextRequest, NextResponse } from "next/server"

export async function POST(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)
  const secret = requestHeaders.get("x-vercel-reval-key")

  if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
    return NextResponse.json({ message: "Invalid secret" }, { status: 401 })
  }

  revalidateTag("components", "max")

  return NextResponse.json({ revalidated: true, now: Date.now() })
}
src/app/components/layout.tsx
파일 저장

import { docsConfig } from "@/config/docs"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Footer } from "@/components/footer"
import { Header } from "@/components/header"
import { DocsSidebarNav } from "@/components/sidebar-nav"

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="relative w-full">
      <Header />
      <div>
        <div className="items-start lg:grid lg:grid-cols-[340px_minmax(0,1fr)] ">
          <aside className="sticky top-0 pb-4 z-30 hidden h-[calc(100vh-6rem)] w-full shrink-0 lg:block pt-4 pl-4 ">
            <div className="rounded-2xl bg-background h-full border-border border">
              <ScrollArea className="h-full">
                <DocsSidebarNav items={docsConfig} />
              </ScrollArea>
            </div>
          </aside>

          <div className="p-4">{children}</div>
        </div>
      </div>
      <Footer />
    </div>
  )
}
src/app/components/page.tsx
파일 저장

import { getAllComponents } from "@/lib/get-components"
import ComponentCard from "@/components/component-card"

export default function Page() {
  const components = getAllComponents()

  return (
    <main className="flex-1 justify-center w-full">
      <div className="rounded-2xl bg-background py-6 lg:gap-10 lg:py-6 border-border border p-6">
        <h1 className="text-4xl font-bold mb-8 font-calendas">Components</h1>
        <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
          {components.map((component) => (
            <ComponentCard key={component.name} component={component} />
          ))}
        </div>
      </div>
    </main>
  )
}
src/app/docs/[[...slug]]/page.tsx
파일 저장

import fs from "node:fs"
import path from "node:path"
import { Metadata } from "next"
import Balancer from "react-wrap-balancer"

import { DocPageProps } from "@/types/types"
import { siteConfig } from "@/config/site"
import { CONTENT_DIRECTORY, getDocFromParams } from "@/lib/get-docs"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import { DocsPager } from "@/components/doc-pager"
import { DashboardTableOfContents } from "@/components/toc"
import { DocBreadcrumb } from "@/components/doc-breadcrumb"
import { DocAuthor } from "@/components/doc-author"
import { CopyPageMenu } from "@/components/copy-page-menu"
import { getComponentByName } from "@/lib/get-components"

export const runtime = "nodejs"
export const dynamic = "force-static"

export async function generateMetadata(props: DocPageProps): Promise<Metadata> {
  const params = await props.params
  const doc = await getDocFromParams({ params })

  if (!doc) {
    return {}
  }

  const urlSlug = doc.slug.split("/").pop()

  let ogUrl

  try {
    const component = getComponentByName(urlSlug!)
    ogUrl = component?.thumbnail?.url || siteConfig.ogImage
  } catch (error) {
    console.error("Error fetching component:", error)
    ogUrl = siteConfig.ogImage
  }

  return {
    title: doc.title,
    description:
      doc.description === "null" ? siteConfig.description : doc.description,
    openGraph: {
      title: doc.title,
      description:
        doc.description === "null" ? siteConfig.description : doc.description,
      type: "article",
      url: doc.slug,
      images: [
        {
          url: ogUrl,
          width: 1200,
          height: 630,
          alt: siteConfig.name,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: doc.title,
      description: doc.description,
      images: [ogUrl],
      creator: "@nonzeroexitcode",
    },
  }
}

export function generateStaticParams() {
  const targets = fs.readdirSync(path.join(process.cwd(), CONTENT_DIRECTORY), {
    // Read nested directories and files
    recursive: true,
  })

  const files = []

  for (const target of targets) {
    // Skip directories
    if (
      fs
        .lstatSync(
          path.join(process.cwd(), CONTENT_DIRECTORY, target.toString())
        )
        .isDirectory()
    ) {
      continue
    }

    // Add files as valid paths
    files.push(target)
  }

  // Return the list of files we want to match with, removing the `.mdx` suffix and breaking them up by directory.
  return files.map((file) => ({
    slug: file.toString().replace(".mdx", "").split("/"),
  }))
}

export default async function DocPage(props: DocPageProps) {
  const params = await props.params
  const doc = await getDocFromParams({ params })

  const toc = doc.toc

  const componentType =
    params.slug?.[1]?.charAt(0).toUpperCase() +
      params.slug?.[1]?.slice(1).toLowerCase() || "Getting Started"

  // Generate current URL for markdown links
  const currentUrl = `https://fancycomponents.dev/docs/${params.slug?.join("/") || ""}`

  // Extract plain text content from the page (simplified version)
  // For now, using description. Could be enhanced to extract more content client-side
  const plainTextContent = doc.description || "No description available"

  return (
    <main className="xl:grid xl:grid-cols-[minmax(0,1fr)_340px] justify-center w-full">
      <div className="rounded-2xl bg-background py-6 lg:gap-10 lg:py-6 border-border border">
        <div data-algolia-crawl className="px-4 md:px-8 flex flex-col">
          <div className="flex items-start justify-between gap-4">
            <DocBreadcrumb componentType={componentType} title={doc.title} />
            <div className="flex-shrink-0">
              <CopyPageMenu 
                title={doc.title}
                content={plainTextContent}
                currentUrl={currentUrl}
              />
            </div>
          </div>
          <div className="">
            <h1
              className={cn(
                "scroll-m-20 text-3xl text-pretty md:text-5xl font-calendas tracking-tight"
              )}
            >
              {doc.title}
            </h1>
          </div>
          <div className="">{/* Description and author */}
            {!!doc.description && doc.description !== "null" && (
              <p className="text-sm md:text-lg text-muted-foreground pt-2 md:pt-4">
                <Balancer>{doc.description}</Balancer>
              </p>
            )}
            <DocAuthor author={doc.author} />
          </div>

          <div className="mb-12 pt-8 space-y-6">{doc.body}</div>
          <div data-algolia-ignore className="">
            <DocsPager doc={doc} />
          </div>
        </div>
      </div>
      {doc.toc && (
        <div className="hidden text-base xl:block sticky top-4 pt-0 pb-4 h-[calc(100vh-8rem)] pl-4">
          <div className="bg-background rounded-2xl border">
            <ScrollArea className="">
              <DashboardTableOfContents toc={toc} />
            </ScrollArea>
          </div>
        </div>
      )}
    </main>
  )
}
src/app/docs/layout.tsx
파일 저장

import { docsConfig } from "@/config/docs"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Footer } from "@/components/footer"
import { Header } from "@/components/header"
import { DocsSidebarNav } from "@/components/sidebar-nav"

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="relative w-full">
      <Header />
      <div>
        <div className="items-start lg:grid lg:grid-cols-[340px_minmax(0,1fr)] ">
          <aside className="sticky top-0 pb-4 z-30 hidden h-[calc(100vh-6rem)] w-full shrink-0 lg:block pt-4 pl-4 ">
            <div className="rounded-2xl bg-background h-full border-border border">
              <ScrollArea className="h-full">
                <DocsSidebarNav items={docsConfig} />
              </ScrollArea>
            </div>
          </aside>

          <div className="p-4">{children}</div>
        </div>
      </div>
      <Footer />
    </div>
  )
}
src/app/globals.css
파일 저장

@import "tailwindcss";

@source '../**/*.{ts,tsx}';

@custom-variant dark (&:is(.dark *));

@theme inline {
  --font-calendas: Calendas Plus, sans-serif;
  --font-overused-grotesk: Overused Grotesk, sans-serif;
  --font-cotham: Cotham Sans;
  --font-VT323: VT323;
  --font-tiny5: Tiny5;
  --font-azeret-mono: Azeret Mono;
  --font-fira-mono: Fira Mono, monospace;
  --font-noto-sans-symbols: Noto Sans Symbols;
  --font-satoshi: Satoshi, sans-serif;

  --font-sans: var(--font-overused-grotesk);
  --font-mono: var(--font-fira-mono);

  --color-lavender: #ecbfff;
  --color-reddish: #ff4a48;
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-ring: hsl(var(--ring));
  --color-background: var(--background);
  --color-foreground: var(--foreground);

  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);

  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);

  --color-destructive: hsl(var(--destructive));
  --color-destructive-foreground: hsl(var(--destructive-foreground));

  --color-muted: hsl(var(--muted));
  --color-muted-foreground: hsl(var(--muted-foreground));

  --color-accent: hsl(var(--accent));
  --color-accent-foreground: hsl(var(--accent-foreground));

  --color-popover: hsl(var(--popover));
  --color-popover-foreground: hsl(var(--popover-foreground));

  --color-card: hsl(var(--card));
  --color-card-foreground: hsl(var(--card-foreground));

  --color-blue: var(--blue);
  --color-blue-50: var(--blue-50);
  --color-blue-100: var(--blue-100);
  --color-blue-200: var(--blue-200);
  --color-blue-300: var(--blue-300);
  --color-blue-400: var(--blue-400);
  --color-blue-500: var(--blue-500);
  --color-blue-600: var(--blue-600);
  --color-blue-700: var(--blue-700);
  --color-blue-800: var(--blue-800);
  --color-blue-900: var(--blue-900);
  --color-blue-950: var(--blue-950);

  --color-editor-background: var(--editor-bg);
  --color-editor-border: var(--editor-border);

  --color-primary-red: var(--red);
  --color-primary-orange: var(--orange);
  --color-primary-pink: var(--pink);
  --color-primary-blue: var(--blue-700);

  --color-teal: var(--teal);
  --color-teal-foreground: var(--teal-foreground);

  --color-yellow: var(--yellow);
  --color-yellow-foreground: var(--yellow-foreground);

  --radius-lg: var(--radius);
  --radius-md: calc(var(--radius) - 2px);
  --radius-sm: calc(var(--radius) - 4px);

  --animate-accordion-down: accordion-down 0.2s ease-out;
  --animate-accordion-up: accordion-up 0.2s ease-out;
  --animate-circling: circling;
  --animate-background-gradient: background-gradient;
  --animate-slide-in-from-left: slide-in-from-left 0.3s ease-out;
  --animate-slide-out-to-left: slide-out-to-left 0.3s ease-in;
  --animate-slide-in-from-right: slide-in-from-right 0.3s ease-out;
  --animate-slide-out-to-right: slide-out-to-right 0.3s ease-in;
  --animate-slide-in-from-top: slide-in-from-top 0.3s ease-out;
  --animate-slide-out-to-top: slide-out-to-top 0.3s ease-in;
  --animate-slide-in-from-bottom: slide-in-from-bottom 0.3s ease-out;
  --animate-slide-out-to-bottom: slide-out-to-bottom 0.3s ease-in;

  @keyframes accordion-down {
    from {
      height: 0;
    }
    to {
      height: var(--radix-accordion-content-height);
    }
  }
  @keyframes accordion-up {
    from {
      height: var(--radix-accordion-content-height);
    }
    to {
      height: 0;
    }
  }
  @keyframes slide-in-from-left {
    0% {
      transform: translateX(-100%);
    }
    100% {
      transform: translateX(0);
    }
  }
  @keyframes slide-out-to-left {
    from {
      transform: translateX(0);
    }
    to {
      transform: translateX(-100%);
    }
  }
  @keyframes slide-in-from-right {
    from {
      transform: translateX(100%);
    }
    to {
      transform: translateX(0);
    }
  }
  @keyframes slide-out-to-right {
    from {
      transform: translateX(0);
    }
    to {
      transform: translateX(100%);
    }
  }
  @keyframes slide-in-from-top {
    from {
      transform: translateY(-100%);
    }
    to {
      transform: translateY(0);
    }
  }
  @keyframes slide-out-to-top {
    from {
      transform: translateY(0);
    }
    to {
      transform: translateY(-100%);
    }
  }
  @keyframes slide-in-from-bottom {
    from {
      transform: translateY(100%);
    }
    to {
      transform: translateY(0);
    }
  }
  @keyframes slide-out-to-bottom {
    from {
      transform: translateY(0);
    }
    to {
      transform: translateY(100%);
    }
  }
  @keyframes circling {
    0% {
      transform: rotate(
          calc(var(--circling-direction, 1) * (var(--circling-offset) * 1deg))
        )
        translate(calc(var(--circling-radius) * 1px), 0)
        rotate(
          calc(var(--circling-direction, 1) * (var(--circling-offset) * -1deg))
        );
    }
    100% {
      transform: rotate(
          calc(
            var(--circling-direction, 1) *
              (360deg + (var(--circling-offset) * 1deg))
          )
        )
        translate(calc(var(--circling-radius) * 1px), 0)
        rotate(
          calc(
            var(--circling-direction, 1) *
              (-360deg + (var(--circling-offset) * -1deg))
          )
        );
    }
  }
  @keyframes background-gradient {
    0%,
    100% {
      transform: translate(0, 0);
      animation-delay: var(--background-gradient-delay, 0s);
    }
    20% {
      transform: translate(
        calc(100% * var(--tx-1, 1)),
        calc(100% * var(--ty-1, 1))
      );
    }
    40% {
      transform: translate(
        calc(100% * var(--tx-2, -1)),
        calc(100% * var(--ty-2, 1))
      );
    }
    60% {
      transform: translate(
        calc(100% * var(--tx-3, 1)),
        calc(100% * var(--ty-3, -1))
      );
    }
    80% {
      transform: translate(
        calc(100% * var(--tx-4, -1)),
        calc(100% * var(--ty-4, -1))
      );
    }
  }
}

@utility container {
  margin-inline: auto;
  padding-inline: 2rem;
  @media (width >= --theme(--breakpoint-sm)) {
    max-width: none;
  }
  @media (width >= 1400px) {
    max-width: 1400px;
  }
}

@utility focus-outline {
  position: relative;
  
  &::after {
    content: '';
    position: absolute;
    inset: 0 -0.5rem;
    inset-block: -0.25rem;
    border: 2px solid transparent;
    border-radius: 0.75rem;
    pointer-events: none;
    transform: scale(0.95);
    opacity: 0;
    /* transition: transform 200ms ease-out, opacity 200ms ease-out, border-color 200ms ease-out; */
  }
  
  &:focus-visible {
    outline: none;
    
    &::after {
      border-color: var(--focus-outline-color, var(--color-primary-blue));
      transform: scale(1);
      opacity: 1;
    }
  }
}

@utility focus-ring {
  outline: 2px solid transparent;
  outline-offset: 2px;
  /* transition: outline-color 200ms ease-out, outline-offset 200ms ease-out; */
  
  &:focus-visible {
    outline-color: var(--focus-ring-color, var(--color-primary-blue));
  }
}

@utility focus-primary {
  @apply focus-outline [--focus-outline-color:var(--color-primary-blue)];
}

/*
  The default border color has changed to `currentColor` in Tailwind CSS v4,
  so we've added these compatibility styles to make sure everything still
  looks the same as it did with Tailwind CSS v3.

  If we ever want to remove these styles, we need to add an explicit border
  color utility to any element that depends on these defaults.
*/
@layer base {
  *,
  ::after,
  ::before,
  ::backdrop,
  ::file-selector-button {
    border-color: var(--color-gray-200, currentColor);
  }
}

@layer base {
  :root {
    --background: #ffffff;
    --foreground: #0c0a09;

    --card: 0 0% 100%;
    --card-foreground: 20 14.3% 4.1%;

    --popover: 0 0% 100%;
    --popover-foreground: 20 14.3% 4.1%;

    --primary: #0c0a09;
    --primary-foreground: #78716c;

    --secondary: #a1a1a6;
    --secondary-foreground: #f4f4f5;

    --muted: 60 4.8% 95.9%;
    --muted-foreground: 25 5.3% 44.7%;

    --accent: 60 4.8% 95.9%;
    --accent-foreground: 24 9.8% 10%;

    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 60 9.1% 97.8%;

    --border: 20 5.9% 90%;
    --input: 20 5.9% 90%;
    --ring: 20 14.3% 4.1%;

    --radius: 0.5rem;

    /* Other colors */
    --red: #ff5941;
    --orange: #f97316;
    --pink: #e794da;
    --teal: #1f464d;
    --teal-foreground: #3bb6ab;
    --yellow: #eab308;
    --yellow-foreground: #ffd726;

    --editor-bg: #151515;
    --editor-border: #252525;

    --blue: var(--blue-700);
  
    --blue-50: #e8f3ff;
    --blue-100: #d5e8ff;
    --blue-200: #b3d2ff;
    --blue-300: #85b2ff;
    --blue-400: #5684ff;
    --blue-500: #2f56ff;
    --blue-600: #0c22ff;
    --blue-700: #0015ff;
    --blue-800: #0619cd;
    --blue-900: #10229f;
    --blue-950: #0a125c;
  }

  .dark {
    --background: #0c0a09;
    --foreground: #f1f1f3;

    --card: 20 14.3% 4.1%;
    --card-foreground: 60 9.1% 97.8%;

    --popover: 20 14.3% 4.1%;
    --popover-foreground: 60 9.1% 97.8%;

    --primary: #cacace;
    --primary-foreground: #f4f4f5;

    --secondary: #96928f;
    --secondary-foreground: #787778;

    --muted: 12 6.5% 15.1%;
    --muted-foreground: 24 5.4% 63.9%;

    --accent: 12 6.5% 15.1%;
    --accent-foreground: 60 9.1% 97.8%;

    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 60 9.1% 97.8%;

    --border: 12 6.5% 15.1%;
    --input: 12 6.5% 15.1%;
    --ring: 24 5.7% 82.9%;

    --blue: var(--blue-400);

    /* Other colors */
    --red: #ff5941;
    --orange: #f97316;
    --pink: #e794da;
    /* --blue: #5570ff; */
    --teal: #1f464d;
    --teal-foreground: #3bb6ab;
    --yellow: #eab308;
    --yellow-foreground: #ffd726;
  }
}

@layer base {
  * {
    @apply border-border;
  }

  *:focus-visible {
    outline: 2px solid var(--color-primary-blue);
    border-radius: 0.5rem;
    outline-offset: 2px;
  }

  body {
    @apply bg-background text-foreground;
  }

  @font-face {
    font-family: "Overused Grotesk";
    src:
      url("../../public/fonts/overused-grotesk.woff2")
        format("woff2 supports variations"),
      url("../../public/fonts/overused-grotesk.woff2")
        format("woff2-variations");
    font-weight: 300 900;
  }

  @font-face {
    font-family: "Cotham Sans";
    src: url("../../public/fonts/cotham-sans.otf") format("otf");
    font-weight: 400;
  }

  @font-face {
    font-family: "Calendas Plus";
    src: url("../../public/fonts/calendas-plus.woff") format("woff");
    font-weight: 400;
  }

  @font-face {
    font-family: "VT323";
    src: url("../../public/fonts/vt323.ttf") format("truetype");
    font-weight: 400;
  }

  @font-face {
    font-family: "Tiny5";
    src: url("../../public/fonts/tiny5.ttf") format("truetype");
  }

  @font-face {
    font-family: "Azeret Mono";
    src: url("../../public/fonts/azeret-mono.woff2")
      format("woff2-variations");
    font-weight: 100 900;
  }

  @font-face {
    font-family: "Noto Sans Symbols";
    src: url("../../public/fonts/noto-sans.ttf")
      format("truetype-variations");
    font-weight: 100 900;
  }

  @font-face {
    font-family: "Fira Mono";
    src: url("../../public/fonts/fira-mono-regular.ttf") format("truetype");
    font-weight: 400;
  }

  @font-face {
    font-family: "Fira Mono";
    src: url("../../public/fonts/fira-mono-medium.ttf") format("truetype");
    font-weight: 500;
  }

  @font-face {
    font-family: "Fira Mono";
    src: url("../../public/fonts/fira-mono-bold.ttf") format("truetype");
    font-weight: 700;
  }

  @font-face {
    font-family: "Satoshi";
    src: url("../../public/fonts/satoshi.woff2") format("woff2-variations");
    font-weight: 300 900;
  }

  @font-face {
    font-family: "Satoshi Italic";
    src: url("../../public/fonts/satoshi-italic.woff2") format("woff2-variations");
    font-weight: 400;
  }
}
src/app/layout.tsx
파일 저장

import type { Metadata } from "next"

import "./globals.css"

import { siteConfig } from "@/config/site"
import { ThemeProvider } from "@/components/theme-provider"
import { SpeedInsights } from "@vercel/speed-insights/next"

export const metadata: Metadata = {
  title: {
    default: siteConfig.name,
    template: `%s - ${siteConfig.name}`,
  },
  metadataBase: new URL(siteConfig.url),
  description: siteConfig.description,
  keywords: [
    "React",
    "Typescript",
    "Tailwind CSS",
    "Microinteractions",
    "Motion",
    "Creative developers",
  ],
  authors: [
    {
      name: "Daniel Petho",
      url: "https://danielpetho.com",
    },
  ],
  creator: "danielpetho",
  openGraph: {
    type: "website",
    locale: "en_US",
    url: siteConfig.url,
    title: siteConfig.name,
    description: siteConfig.description,
    siteName: siteConfig.name,
    images: [
      {
        url: siteConfig.ogImage,
        width: 1200,
        height: 630,
        alt: siteConfig.name,
      },
    ],
  },
  twitter: {
    card: "summary_large_image",
    title: siteConfig.name,
    description: siteConfig.description,
    images: [siteConfig.ogImage],
    creator: "@nonzeroexitcode",
  },
  icons: {
    icon: "/favicon.ico",
    shortcut: "/favicon-16x16.png",
    apple: "/apple-touch-icon.png",
  },
  manifest: `/site.webmanifest`,
}
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        {/* <script src="https://unpkg.com/react-scan/dist/auto.global.js"/> */}
        <script
          defer
          src="https://cloud.umami.is/script.js"
          data-website-id="dbbf9969-1099-440a-8dcd-84616691e48a"
        ></script>
      </head>
      <body
        className={`font-overused-grotesk bg-background antialiased flex items-center justify-center w-full text-foreground [font-synthesis-weight:none]`}
      >
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <main className="h-full w-full max-w-(--breakpoint-2xl) flex flex-col items-center justify-center">
            {children}
          </main>
        </ThemeProvider>
        <SpeedInsights />
      </body>
    </html>
  )
}
src/app/not-found.tsx
파일 저장

"use client"

import { useRef } from "react"
import Link from "next/link"
import Screensaver from "@/fancy/components/blocks/screensaver"

export default function NotFound() {
  const containerRef = useRef<HTMLDivElement>(null)

  return (
    <div
      className="w-screen px-12 h-screen bg-background overflow-hidden flex items-center justify-center relative"
      ref={containerRef}
    >
      <div className="flex flex-col items-center justify-center z-30 space-y-8">
        <h1 className="text-3xl md:text-6xl font-overused-grotesk ">
          page not found
        </h1>

        <button
          className="text-sm sm:text-base md:text-lg tracking-tight text-white bg-black px-4 py-2 sm:px-5 sm:py-2.5 rounded-lg md:rounded-2xl shadow-2xl hover:scale-105 transition-all duration-300 ease-out"
        >
          <Link href="/docs/introduction">
            Back to docs <span className="font-serif ml-1">→</span>
          </Link>
        </button>
      </div>

      {[...Array(12)].map((_, i) => (
        <Screensaver
          key={i}
          speed={1}
          startPosition={{ x:  10 + i * 1, y: 10 + i * 1 }} // Offset each element's starting position slightly
          startAngle={215} // Keep same angle for all elements
          containerRef={containerRef}
         
        >
          <span
            className="text-[160px] sm:text-[200px] md:text-[240px] lg:text-[300px] font-bold text-black [-webkit-text-stroke-width:2px] sm:[-webkit-text-stroke-width:2.5px] md:[-webkit-text-stroke-width:3px]  lg:[-webkit-text-stroke-width:4px] [-webkit-text-stroke-color:white] align-text-top"
          >
            404
          </span>
        </Screensaver>
      ))}
    </div>
  )
}
src/app/page.tsx
파일 저장

import { getAllComponents } from "@/lib/get-components"
import { LandingHero } from "@/components/landing/landing-hero"

export default function Home() {
  const allComps = getAllComponents()

  return <LandingHero allComps={allComps} />
}
src/app/sitemap.ts
파일 저장

import fs from "node:fs"
import path from "node:path"
import type { MetadataRoute } from "next"

import { siteConfig } from "@/config/site"
import { CONTENT_DIRECTORY } from "@/lib/get-docs"

function getDocSlugs(): string[][] {
  const contentPath = path.join(process.cwd(), CONTENT_DIRECTORY)
  const targets = fs.readdirSync(contentPath, { recursive: true })

  const slugs: string[][] = []

  for (const target of targets) {
    const fullPath = path.join(contentPath, target.toString())
    if (fs.lstatSync(fullPath).isDirectory()) {
      continue
    }
    if (!target.toString().endsWith(".mdx")) {
      continue
    }
    slugs.push(
      target.toString().replace(".mdx", "").replace(/\\/g, "/").split("/")
    )
  }

  return slugs
}

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = siteConfig.url

  const docSlugs = getDocSlugs()
  const docUrls: MetadataRoute.Sitemap = docSlugs.map((slugParts) => ({
    url: `${baseUrl}/docs/${slugParts.join("/")}`,
    lastModified: new Date(),
    changeFrequency: "weekly" as const,
    priority: 0.8,
  }))

  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/components`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.9,
    },
    ...docUrls,
  ]
}
src/components/code-snippet.tsx
파일 저장

"use client"

import React from 'react';
import { CopyButton } from './copy-button';
import { Highlight, PrismTheme } from 'prism-react-renderer';
import theme from '@/styles/prism-theme.json';

interface CodeSnippetProps {
  title?: string;
  code: string;
  language?: string;
}

export const CodeSnippet: React.FC<CodeSnippetProps> = ({
  title,
  code,
  language = 'typescript',
}) => {
  const lines = code.trim().split('\n');

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(code);
    } catch (err) {
      console.warn('Copy failed:', err);
    }
  };

  return (
    <div className="border border-editor-border rounded-2xl overflow-hidden pointer-events-auto">
      {title ? (
        <div className="flex items-center justify-between pl-4 pr-3 py-2 border-b border-editor-border bg-editor-background h-11">
          <h3 className="text-white text-sm font-medium">{title}</h3>
          <CopyButton onCopy={handleCopy} />
        </div>
      ) : null}
      <div className="bg-editor-background py-4 relative overflow-y-auto max-h-[calc(530px-44px)] ">
        {!title && (
          <div className={`absolute ${
            lines.length === 1 
              ? "top-1/2 -translate-y-1/2 right-3" 
              : "top-4 right-3"
          }`}>
            <CopyButton
              onCopy={handleCopy}
            />
          </div>
        )}
        <Highlight
          theme={theme as PrismTheme}
          code={code.trim()}
          language={language}
        >
          {({ className, style, tokens, getLineProps, getTokenProps }) => (
            <pre className={`${className} text-[13px] overflow-x-auto font-mono font-medium`} style={style}>
              {tokens.map((line, i) => (
                <div key={i} {...getLineProps({ line })} className="flex items-center hover:bg-editor-border py-px px-4">
                  <span className="mr-4 select-none text-muted-foreground text-right text-[10px] items-center flex">
                    {i + 1}
                  </span>
                  <span>
                    {line.map((token, key) => (
                      <span key={key} {...getTokenProps({ token })} />
                    ))}
                  </span>
                </div>
              ))}
            </pre>
          )}
        </Highlight>
      </div>
    </div>
  );
};
src/components/component-card.tsx
파일 저장

"use client"

import Image from "next/image"
import Link from "next/link"
import { useState, useRef } from "react"
import { motion, useInView } from "motion/react"
import { cn } from "@/lib/utils"

interface ComponentCardProps {
  component: {
    name: string
    category: string
    thumbnail: { url: string }
    demo: { url: string }
  }
}

export default function ComponentCard({ component }: ComponentCardProps) {
  const [isVideoLoaded, setIsVideoLoaded] = useState(false)
  const ref = useRef(null)
  const isInView = useInView(ref, { once: true })

  const handleVideoLoaded = () => {
    setIsVideoLoaded(true)
  }

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0 }}
      animate={{ opacity: isInView ? 1 : 0 }}
      transition={{ duration: 0.5, ease: "easeOut" }}
    >
      <Link
        href={`/docs/components/${component.category}/${component.name}`}
        className="group relative aspect-video rounded-xl overflow-hidden border block focus-ring"
      >
        {/* Thumbnail Image */}
        <Image
          src={component.thumbnail.url}
          alt={component.name}
          fill
          className="object-cover group-hover:opacity-0"
          sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
        />
        {/* Video on Hover */}
        <video
          src={component.demo.url}
          autoPlay
          loop
          muted
          playsInline
          onLoadedData={handleVideoLoaded}
          className={cn(
            "absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-0",
            isVideoLoaded && "group-hover:opacity-100"
          )}
        />
      </Link>
    </motion.div>
  )
} 
src/components/component-preview.tsx
파일 저장

"use client"

import * as React from "react"
import { Repeat } from "lucide-react"

import { cn } from "@/lib/utils"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Icons } from "@/components/icons"
import { registry } from "@/fancy/index"

import { CodeSnippet } from "./code-snippet"
import { OpenInV0Button } from "./open-in-v0"
import { RestartButton } from "./restart-button"
import { Button } from "./ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "./ui/dropdown-menu"

interface ComponentPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
  name: string
  extractClassname?: boolean
  extractedClassNames?: string
  align?: "center" | "start" | "end"
  framerLink?: string
  description?: string
}

export function ComponentPreview({
  name,
  children,
  className,
  framerLink,
  extractClassname,
  extractedClassNames,
  align = "center",
  description,
  ...props
}: ComponentPreviewProps) {
  const [sourceCode, setSourceCode] = React.useState("")
  const [previewKey, setPreviewKey] = React.useState(0)
  const [showRestartButton, setShowRestartButton] = React.useState(true)

  React.useEffect(() => {
    async function loadSourceCode() {
      try {
        const mod = await import(`../../public/r/${name}.json`)
        const json = mod.default

        // Find the main component file that matches the name
        const mainFile = json.files.find(
          (file: any) => file.path.split("/").pop().replace(".tsx", "") === name
        )

        if (mainFile) {
          setSourceCode(mainFile.content)
        } else {
          console.error(`Could not find main file for ${name}`)
          setSourceCode("")
        }
      } catch (error) {
        console.error(`Failed to load source for ${name}:`, error)
        setSourceCode("")
      }
    }
    loadSourceCode()
  }, [name])

  const handleRestart = React.useCallback(() => {
    setPreviewKey((prev) => prev + 1)
  }, [])

  React.useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "r" || event.key === "R") {
        handleRestart()
      }
      // Toggle restart button visibility with Cmd+D
      if ((event.metaKey || event.ctrlKey) && event.key === "1") {
        event.preventDefault()
        setShowRestartButton((prev) => !prev)
      }
    }

    window.addEventListener("keydown", handleKeyDown)
    return () => window.removeEventListener("keydown", handleKeyDown)
  }, [handleRestart])

  const Preview = React.useMemo(() => {
    const Component = registry[name]?.component

    if (!Component) {
      return (
        <p
          data-algolia-ignore
          className="text text-muted-foreground justify-center items-center flex w-full h-full whitespace-pre"
        >
          Component{" "}
          <code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm whitespace-pre">
            {name}
          </code>{" "}
          not found.
        </p>
      )
    }

    return <Component />
  }, [name])

  return (
    <div
      data-algolia-ignore
      className={cn(
        "relative flex flex-col h-full w-full ",
        className
      )}
      {...props}
    >
      <Tabs defaultValue="preview" className="relative mr-auto w-full">
        <div className="flex items-center justify-between">
          <TabsList className="w-full justify-start rounded-none p-0 h-9 bg-transparent space-x-3 px-3">
            <TabsTrigger
              value="preview"
              className="relative text-base border-b-transparent bg-transparent px-0 font-semibold text-muted-foreground shadow-none transition-colors duration-300 ease-out hover:text-foreground data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:bg-transparent focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue rounded-lg"
            >
              Demo
            </TabsTrigger>
            <TabsTrigger
              value="code"
              className="relative text-base border-b-transparent bg-transparent px-0 font-semibold text-muted-foreground shadow-none transition-colors duration-300 ease-out hover:text-foreground data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:bg-transparent focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue rounded-lg"
            >
              Code
            </TabsTrigger>
          </TabsList>
        </div>
        <TabsContent
          value="preview"
          className="border border-black-500 flex rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
        >
          <div className="w-full flex items-center justify-center rounded-2xl min-h-[530px] overflow-hidden relative max-h-[530px]">
            {/* <div className="absolute top-4 right-4 rounded-full border">

            </div> */}
            <React.Suspense
              fallback={
                <div className="flex items-center justify-center w-full h-full text-sm text-muted-foreground">
                  <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
                  Loading...
                </div>
              }
            >
              {showRestartButton && (
                <div className="absolute right-4 top-4 z-50 flex gap-2 flex-row">
                  {framerLink ? (
                    <DropdownMenu>
                      <DropdownMenuTrigger asChild>
                        <Button
                          variant="outline"
                          size="sm"
                          className="h-8 items-center flex justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
                        >
                          <Repeat className="mr-2 h-4 w-4" />
                          Remix
                        </Button>
                      </DropdownMenuTrigger>
                      <DropdownMenuContent align="end">
                        <DropdownMenuItem>
                          <OpenInV0Button
                            variant="ghost"
                            className="justify-start  text-left px-0 h-6 gap-1  flex w-full border-none"
                            url={`https://fancycomponents.dev/r/${name}.json`}
                          />
                        </DropdownMenuItem>
                        <DropdownMenuItem>
                          <Button
                            variant="ghost"
                            className="justify-start  text-left px-0 h-6 gap-1  flex w-full border-none"
                            onClick={() => window.open(framerLink, "_blank")}
                          >
                            Remix in Framer
                            <Icons.framer className="ml-2 h-3 w-3" />
                          </Button>
                        </DropdownMenuItem>
                      </DropdownMenuContent>
                    </DropdownMenu>
                  ) : (
                    <OpenInV0Button
                      url={`https://fancycomponents.dev/r/${name}.json`}
                    />
                  )}
                  <RestartButton onRestart={handleRestart} />
                </div>
              )}
              <React.Fragment key={previewKey}>{Preview}</React.Fragment>
            </React.Suspense>
          </div>
        </TabsContent>
        <TabsContent value="code">
          <CodeSnippet
            title={name + ".tsx"}
            code={sourceCode}
            language="tsx"
          />
        </TabsContent>
      </Tabs>
    </div>
  )
}
src/components/component-source.tsx
파일 저장

"use client"

import * as React from "react"

import { CodeSnippet } from "./code-snippet"

interface ComponentSourceProps extends React.HTMLAttributes<HTMLDivElement> {
  name: string
}

export function ComponentSource({
  name,
  children,
  className,
  ...props
}: ComponentSourceProps) {
  const [sourceCode, setSourceCode] = React.useState("")

  React.useEffect(() => {
    async function loadSourceCode() {
      try {
        const mod = await import(`../../public/r/${name}.json`)
        const json = mod.default

        // Find the main component file that matches the name
        const mainFile = json.files.find(
          (file: any) =>
            file.path
              .split("/")
              .pop()
              .replace(/\.(tsx|ts)$/, "") === name
        )

        if (mainFile) {
          setSourceCode(mainFile.content)
        } else {
          console.error(`Could not find main file for ${name}`)
          setSourceCode("")
        }
      } catch (error) {
        console.error(`Failed to load source for ${name}:`, error)
        setSourceCode("")
      }
    }
    loadSourceCode()
  }, [name])

  return <CodeSnippet title={name + ".tsx"} code={sourceCode} language="tsx" />
}
src/components/copy-button.tsx
파일 저장

"use client"

import React, { useState, useRef } from 'react';
import { Copy } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { motion, TargetAndTransition, Variants } from 'motion/react';
import { cn } from '@/lib/utils';

interface CopyButtonProps {
  onCopy: () => Promise<void> | void;
  className?: string;
}

const copyIconVariants: Variants = {
  idle: { 
    opacity: 1,
    scale: 1,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copying: { 
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copied: { 
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  }
};

const checkIconVariants: Variants = {
  idle: {
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copying: {
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copied: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.2, ease: "easeOut" }
  }
};

const checkPathVariants: Variants = {
  idle: { 
    pathLength: 0,
    opacity: 0,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copying: { 
    pathLength: 0,
    opacity: 1,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copied: { 
    pathLength: 1,
    opacity: 1,
    transition: { duration: 0.3, ease: "easeOut" }
  }
};

const MotionButton = motion.create(Button);

export const CopyButton: React.FC<CopyButtonProps> = ({ onCopy, className }) => {
  const [status, setStatus] = useState<"idle" | "copying" | "copied">("idle");
  const [backgroundState, setBackgroundState] = useState<"hidden" | "entering" | "centered" | "leaving">("hidden");
  const [entryDirection, setEntryDirection] = useState({ x: 0, y: 0 });
  const [leaveDirection, setLeaveDirection] = useState({ x: 0, y: 0 });
  const buttonRef = useRef<HTMLButtonElement>(null);

  const handleCopy = async () => {
    if (status !== "idle") return;
    
    setStatus("copying");
    await onCopy();
    
    setTimeout(() => {
      setStatus("copied");
    }, 100);
    
    setTimeout(() => {
      setStatus("idle");
    }, 2000);
  };

  const calculateDirection = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (!buttonRef.current) return { x: 0, y: 0 };
    
    const rect = buttonRef.current.getBoundingClientRect();
    const centerX = rect.left + rect.width / 2;
    const centerY = rect.top + rect.height / 2;
    
    const mouseX = e.clientX;
    const mouseY = e.clientY;
    
    const offsetX = mouseX - centerX;
    const offsetY = mouseY - centerY;
  
    
    return { x: offsetX, y: offsetY };
  };

  const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
    const direction = calculateDirection(e);
    setEntryDirection(direction);
    
    // phase 1: instantly spawn at cursor position
    setBackgroundState("entering");
    
    // phase 2: animate to center after a brief moment
    setTimeout(() => {
      setBackgroundState("centered");
    }, 10);
  };

  const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
    const direction = calculateDirection(e);
    setLeaveDirection(direction);
    
    // phase 3: animate to leave direction
    setBackgroundState("leaving");
    
    // reset to hidden after animation completes
    setTimeout(() => {
      setBackgroundState("hidden");
    }, 150);
  };

  const getBackgroundAnimation = () => {
    switch (backgroundState) {
      case "hidden":
        return {
          opacity: 0,
          x: entryDirection.x,
          y: entryDirection.y,
          scale: 0.6,
          transition: { duration: 0 }
        };
      case "entering":
        return {
          opacity: 0,
          x: entryDirection.x,
          y: entryDirection.y,
          scale: 0.6,
          transition: { duration: 0 }
        };
      case "centered":
        return {
          opacity: 1,
          x: 0,
          y: 0,
          scale: 1,
          transition: { duration: 0.15, ease: "easeOut" }
        };
      case "leaving":
        return {
          opacity: 0,
          x: leaveDirection.x,
          y: leaveDirection.y,
          scale: 1,
          transition: { duration: 0.15, ease: "easeOut" }
        };
      default:
        return {
          opacity: 0,
          x: 0,
          y: 0,
          scale: 0.6,
          transition: { duration: 0 }
        };
    }
  };

  return (
    <div className="relative">
      {/* animated Background */}
      <motion.div
        className="absolute inset-0 bg-editor-border rounded-md"
        animate={getBackgroundAnimation() as TargetAndTransition}
      />
      
      <MotionButton
        ref={buttonRef}
        onClick={handleCopy}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        variant="ghost"
        size="icon"
        className={cn("relative text-muted-foreground cursor-pointer w-8 h-8 hover:text-white hover:scale-105 duration-300 transition-[scale,color,background-color,opacity] ease-out hover:bg-transparent bg-none focus:outline-none! focus-visible:ring-2! focus-visible:ring-offset-0! focus-visible:ring-white!", className)}
        aria-label="Copy code"
        whileTap={{ scale: 0.9 }}
        transition={{ type: "spring", stiffness: 400, damping: 30 }}
        // disabled={status !== "idle"}
      >
        <div className="relative w-4 h-4">
          {/* Copy Icon */}
          <motion.div
            className="absolute inset-0 flex items-center justify-center"
            animate={status}
            variants={copyIconVariants}
          >
            <Copy className="w-4 h-4" />
          </motion.div>
          
          {/* Check Icon */}
          <motion.div
            className="absolute inset-0 flex items-center justify-center"
            animate={status}
            variants={checkIconVariants}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width={16}
              height={16}
              viewBox="0 0 24 24"
              fill="none"
              stroke="white"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <motion.path
                d="M4 12 9 17L20 6"
                animate={status}
                variants={checkPathVariants}
              />
            </svg>
          </motion.div>
        </div>
      </MotionButton>
    </div>
  );
};
src/components/copy-page-menu.tsx
파일 저장

"use client"

import React, { useState } from "react"
import { Copy, FileText, MoreHorizontal, Check, ChevronDown, CopyIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Icons } from "@/components/icons"
import { motion, Variants } from 'motion/react'

interface CopyPageMenuProps {
  title: string
  content: string // The main content to copy
  currentUrl: string // Current page URL for markdown link generation
}

const copyIconVariants: Variants = {
  idle: { 
    opacity: 1,
    scale: 1,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copying: { 
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copied: { 
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  }
}

const checkIconVariants: Variants = {
  idle: {
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copying: {
    opacity: 0,
    scale: 0.8,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copied: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.2, ease: "easeOut" }
  }
}

const checkPathVariants: Variants = {
  idle: { 
    pathLength: 0,
    opacity: 0,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copying: { 
    pathLength: 0,
    opacity: 1,
    transition: { duration: 0.2, ease: "easeOut" }
  },
  copied: { 
    pathLength: 1,
    opacity: 1,
    transition: { duration: 0.3, ease: "easeOut" }
  }
}

export function CopyPageMenu({ title, content, currentUrl }: CopyPageMenuProps) {
  const [status, setStatus] = useState<"idle" | "copying" | "copied">("idle")

  const copyToClipboard = async (text: string) => {
    try {
      await navigator.clipboard.writeText(text)
    } catch (error) {
      console.error("Failed to copy to clipboard:", error)
      throw error
    }
  }

  const handleCopyAsMarkdown = async () => {
    if (status !== "idle") return
    
    setStatus("copying")
    
    try {
      const url = new URL(currentUrl)
      const markdownPath = `${url.pathname}.md`
      const response = await fetch(markdownPath)
      
      if (!response.ok) {
        throw new Error(`Failed to fetch markdown: ${response.statusText}`)
      }
      
      const fullMarkdownContent = await response.text()
      await copyToClipboard(fullMarkdownContent)
      
      setTimeout(() => {
        setStatus("copied")
      }, 100)
      
      setTimeout(() => {
        setStatus("idle")
      }, 2000)
    } catch (error) {
      console.error("Failed to copy markdown:", error)
      setStatus("idle")
    }
  }

  const handleViewAsMarkdown = () => {
    const markdownUrl = `${currentUrl}.md`
    window.open(markdownUrl, "_blank")
  }

  const handleOpenInChatGPT = () => {
    const prompt = `Please help me understand this documentation: ${currentUrl}.md`
    const chatGPTUrl = `https://chat.openai.com/?model=gpt-4&q=${encodeURIComponent(prompt)}`
    window.open(chatGPTUrl, "_blank")
  }

  const handleOpenInClaude = () => {
    const prompt = `Please help me understand this documentation: ${currentUrl}.md`
    const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`
    window.open(claudeUrl, "_blank")
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <motion.button
          className="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm cursor-pointer font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 w-8"
          //whileTap={{ scale: 0.9 }}
          transition={{ type: "spring", stiffness: 400, damping: 30 }}
        >
          <div className="relative w-4 h-4">
            {/* Copy Icon */}
            <motion.div
              className="absolute inset-0 flex items-center justify-center"
              animate={status}
              variants={copyIconVariants}
            >
              <CopyIcon className="w-4 h-4" />
            </motion.div>
            
            {/* Check Icon */}
            <motion.div
              className="absolute inset-0 flex items-center justify-center"
              animate={status}
              variants={checkIconVariants}
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width={16}
                height={16}
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <motion.path
                  d="M4 12 9 17L20 6"
                  animate={status}
                  variants={checkPathVariants}
                />
              </svg>
            </motion.div>
          </div>
        </motion.button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem>
          <Button
            variant="ghost"
            className="justify-start text-left px-0 h-6 gap-2 flex w-full border-none"
            onClick={handleCopyAsMarkdown}
          >
            {status === "copied" ? (
              <Check className="h-4 w-4" />
            ) : (
              <Copy className="h-4 w-4" />
            )}
            Copy page as Markdown for LLMs
          </Button>
        </DropdownMenuItem>

        <DropdownMenuItem>
          <Button
            variant="ghost"
            className="justify-start text-left px-0 h-6 gap-2 flex w-full border-none"
            onClick={handleViewAsMarkdown}
          >
            <FileText className="h-4 w-4" />
            View as Markdown
          </Button>
        </DropdownMenuItem>

        <DropdownMenuItem>
          <Button
            variant="ghost"
            className="justify-start text-left px-0 h-6 gap-2 flex w-full border-none"
            onClick={handleOpenInChatGPT}
          >
            <Icons.openai className="h-4 w-4" />
            Open in ChatGPT
          </Button>
        </DropdownMenuItem>

        <DropdownMenuItem>
          <Button
            variant="ghost"
            className="justify-start text-left px-0 h-6 gap-2 flex w-full border-none"
            onClick={handleOpenInClaude}
          >
            <Icons.anthropic className="h-4 w-4" />
            Open in Claude
          </Button>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
} 
src/components/doc-author.tsx
파일 저장

import { ExternalLink, ExternalLinkIcon } from "lucide-react"

interface DocAuthorProps {
  author: string
}

export function DocAuthor({ author }: DocAuthorProps) {
  if (!author || author.length === 0 || author === "undefined") {
    return null
  }

  return (
    <p className="pt-4 text text-muted-foreground flex flex-row whitespace-pre text-lg">
      by {" "}
      {author.match(/\[([^\]]+)\]\(([^)]+)\)/g)
        ? author.split(",").map((authorItem, i) => {
            const match = authorItem.match(/\[([^\]]+)\]\(([^)]+)\)/)
            if (match) {
              return (
                <span key={i}>
                  {i > 0 && " "}
                  <a
                    href={match[2]}
                    className="no-underline text-blue hover:text-blue-400 dark:hover:text-blue-500 dark:text-blue-300 duration-300 transition-colors ease-out inline-flex items-center font-medium"
                  >
                    {match[1].trim()}
                    <ExternalLinkIcon
                      className="ml-1 mt-0.5"
                      size={14}
                      strokeWidth={2.5}
                    />
                  </a>
                </span>
              )
            }
            return (
              <span key={i}>
                {i > 0 && " "}
                {authorItem.trim()}
              </span>
            )
          })
        : author.match(/(.*?)\s*<(https?:\/\/[^>]+)>/g)
          ? author.split(",").map((authorItem, i) => {
              const match = authorItem.match(/(.*?)\s*<(https?:\/\/[^>]+)>/)
              if (match) {
                return (
                  <span key={i}>
                    {i > 0 && " "}
                    <a
                      href={match[2]}
                      className="no-underline text-blue hover:text-blue-400 dark:hover:text-blue-300 dark:text-blue-400 duration-300 transition-colors ease-out inline-flex items-center font-medium"
                    >
                      {match[1].trim()}
                      <ExternalLinkIcon
                        className="ml-1 mt-0.5"
                        size={14}
                        strokeWidth={2.5}
                      />
                    </a>
                  </span>
                )
              }
              return (
                <span key={i}>
                  {i > 0 && " "}
                  {authorItem.trim()}
                </span>
              )
            })
          : author}
    </p>
  )
}
src/components/doc-breadcrumb.tsx
파일 저장

interface DocBreadcrumbProps {
  componentType: string
  title: string
}

export function DocBreadcrumb({ componentType, title }: DocBreadcrumbProps) {
  return (
    <div className="pb-6 flex items-center space-x-1 text-[13px] md:text-base text-muted-foreground">
      <div className="font-medium whitespace-nowrap">
        Docs
      </div>
      <span className="font-serif">&#8594;</span>
      <div
        data-algolia-level-0
        className="font-medium text-muted-foreground"
      >
        {componentType}
      </div>
      <span className="font-serif">&#8594;</span>
      <div className="font-medium text-foreground">{title}</div>
    </div>
  )
}
src/components/doc-pager.tsx
파일 저장

"use client"

import Link from "next/link"
import { motion } from "motion/react"

import { NavItem, NavItemWithChildren } from "@/types/nav"
import { Doc } from "@/types/types"
import { docsConfig } from "@/config/docs"

const MotionLink = motion.create(Link)

interface DocsPagerProps {
  doc: Doc
}

export function DocsPager({ doc }: DocsPagerProps) {
  const pager = getPagerForDoc(doc)

  if (!pager) {
    return null
  }

  return (
    <div
      className={`flex flex-row items-center ${pager.prev && pager.next ? "justify-between" : pager.next ? "justify-end" : "justify-start"} text`}
    >
      {pager?.prev?.href && (
        <MotionLink
          href={pager.prev.href}
          className="items-center flex flex-row justify-center bg-muted rounded-xl pl-2 pr-6 py-2 hover:bg-muted/60 duration-300 ease-out transition-[colors,background-color] focus-ring"
          whileHover="hover"
          whileTap={{ scale: 0.97 }}
        >
          <motion.p 
            className="font-serif sm:mr-2 h-7 w-7 rotate-180"
            variants={{
              hover: {
                x: 5,
                transition: {
                  duration: 0.3,
                  ease: "easeOut"
                }
              }
            }}
          >
            &#8594;
          </motion.p>
          <span className="truncate hidden sm:block">{pager.prev.title}</span>
        </MotionLink>
      )}
      {pager?.next?.href && (
        <MotionLink
          href={pager.next.href}
          className="flex flex-row hover:bg-muted/60 duration-300 ease-out transition-[colors,background-color] items-center justify-center rounded-xl pr-2 pl-6 py-2 bg-muted focus-ring"
          whileHover="hover"
          whileTap={{ scale: 0.97 }}
        >
          <span className="truncate hidden sm:block">{pager.next.title}</span>
          <motion.span 
            className="font-serif h-7 w-7 sm:ml-2"
            variants={{
              hover: {
                x: 5,
                transition: {
                  duration: 0.3,
                  ease: "easeOut"
                }
              }
            }}
          >
            &#8594;
          </motion.span>
        </MotionLink>
      )}
    </div>
  )
}

export function getPagerForDoc(doc: Doc) {
  const flattenedLinks = flatten(docsConfig)
  const activeIndex = flattenedLinks.findIndex(
    (link) => doc.slug === link.href?.replace(/^\/docs\//, "")
  )
  const prev = activeIndex > 0 ? flattenedLinks[activeIndex - 1] : null
  const next =
    activeIndex < flattenedLinks.length - 1
      ? flattenedLinks[activeIndex + 1]
      : null
  return {
    prev,
    next,
  }
}

export function flatten(links: NavItemWithChildren[]): NavItem[] {
  return links
    .reduce<NavItem[]>((flat, link) => {
      return flat.concat(link.items?.length ? flatten(link.items) : link)
    }, [])
    .filter((link) => !link?.disabled)
}
src/components/doc-search-hit.tsx
파일 저장

import { useState } from "react"
import Link from "next/link"
import { CornerDownLeft, File, Hash, Text } from "lucide-react"

interface DocSearchHitProps {
  hit: any
}

export function DocSearchHit({ hit }: DocSearchHitProps) {
  // Compose hierarchy string
  const hierarchy = [hit.hierarchy.lvl0, hit.hierarchy.lvl1, hit.hierarchy.lvl2]
    .filter(Boolean)
    .join(" / ")
  const mainHierarchy = hit.hierarchy.lvl1 || ""

  // Determine which icon to show based on hierarchy and content
  let LeadingIcon = Text
  if (!hit.content || hit.content.trim() === "") {
    if (hit.hierarchy.lvl2) {
      LeadingIcon = Hash
    } else if (hit.hierarchy.lvl1) {
      LeadingIcon = File
    }
  }

  // Determine what to show as the main content
  let mainContent = null
  let mainContentHighlight = null
  if (!hit.content || hit.content.trim() === "") {
    if (hit.hierarchy.lvl2) {
      mainContent = hit.hierarchy.lvl2
      mainContentHighlight =
        hit._highlightResult?.hierarchy?.lvl2?.value || hit.hierarchy.lvl2
    } else if (hit.hierarchy.lvl1) {
      mainContent = hit.hierarchy.lvl1
      mainContentHighlight =
        hit._highlightResult?.hierarchy?.lvl1?.value || hit.hierarchy.lvl1
    }
  }

  return (
    <Link
      href={hit.url}
      className="w-full px-2 py-3 flex gap-4 items-center cursor-pointer rounded-xl"
    >
      <LeadingIcon className="w-4 h-4 text-foreground shrink-0 stroke-[1.5px]" />
      <div className="min-w-0 flex-1">
        <div className="items-center gap-2 text-foreground flex">
          <span className="text-sm font-medium truncate">
            <style jsx>{`
              mark {
                color: #2563eb;
                background: none;
                font-weight: 600;
                padding: 0;
                overflow: hidden;
              }
            `}</style>
            {mainContent ? (
              <span
                dangerouslySetInnerHTML={{
                  __html: mainContentHighlight,
                }}
              />
            ) : (
              <span
                dangerouslySetInnerHTML={{
                  __html:
                    hit._snippetResult?.content?.value ||
                    hit._highlightResult?.content?.value ||
                    hit.content,
                }}
              />
            )}
          </span>
        </div>
        {hierarchy &&
          hit.type !== "lvl1" &&
          (hit._highlightResult?.hierarchy?.lvl1?.value !== mainContent ||
            hit._highlightResult?.hierarchy?.lvl1?.value !== mainHierarchy) && (
            <div className="text-xs text-muted-foreground leading-tight truncate">
              <style jsx>{`
                mark {
                  color: #2563eb;
                  background: none;
                  font-weight: 600;
                  padding: 0;
                }
              `}</style>
              <span
                dangerouslySetInnerHTML={{
                  __html:
                    hit._highlightResult?.hierarchy?.lvl1?.value ||
                    mainHierarchy,
                }}
              />
            </div>
          )}
      </div>
      <div className="ml-auto" data-enter-icon>
        <CornerDownLeft className="w-4 h-4 text-foreground stroke-[1.5px]" />
      </div>
    </Link>
  )
}
src/components/doc-search.tsx
파일 저장

import { DocSearch } from "@docsearch/react"
import { DocSearchHit } from "./doc-search-hit"

export function Search() {
  return (
    <DocSearch
      appId="2X8YUQBTLC"
      indexName="fancycomponents"
      apiKey="6f798ebaa6226dd06e44bd898b32893f"
      placeholder="Search documentation..."
      disableUserPersonalization
      maxResultsPerGroup={10}
      initialQuery="Text"
      hitComponent={({ hit }) => <DocSearchHit hit={hit} />}
      translations={{
        button: {
          buttonText: 'Search docs...',
          buttonAriaLabel: 'Search documentation',
        },
        modal: {
          searchBox: {
            resetButtonTitle: 'Clear the query',
            resetButtonAriaLabel: 'Clear the query',
            cancelButtonText: 'Close',
            cancelButtonAriaLabel: 'Close',
            searchInputLabel: 'Search',
          },
          startScreen: {
            recentSearchesTitle: 'Recent',
            noRecentSearchesText: 'No recent searches',
            saveRecentSearchButtonTitle: 'Save this search',
            removeRecentSearchButtonTitle: 'Remove this search from history',
            favoriteSearchesTitle: 'Favorite',
            removeFavoriteSearchButtonTitle: 'Remove this search from favorites',
          },
          errorScreen: {
            titleText: 'Unable to fetch results',
            helpText: 'You might want to check your network connection.',
          },
          footer: {
            selectText: 'to select',
            selectKeyAriaLabel: 'Enter key',
            navigateText: 'to navigate',
            navigateUpKeyAriaLabel: 'Arrow up',
            navigateDownKeyAriaLabel: 'Arrow down',
            closeText: 'to close',
            closeKeyAriaLabel: 'Escape key',
            searchByText: 'Search by',
          },
          noResultsScreen: {
            noResultsText: 'No results for',
            suggestedQueryText: 'Try searching for',
            reportMissingResultsText: 'Believe this query should return results?',
            reportMissingResultsLinkText: 'Let us know.',
          },
        },
      }}
    />
  )
}
src/components/explanation-demo.tsx
파일 저장

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Icons } from "@/components/icons"
import { registry } from "@/fancy/index"

interface ExplanationDemoProps extends React.HTMLAttributes<HTMLDivElement> {
  name: string
  extractClassname?: boolean
  extractedClassNames?: string
  align?: "center" | "start" | "end"
  framerLink?: string
  description?: string
}

export function ExplanationDemo({
  name,
  children,
  className,
  framerLink,
  extractClassname,
  extractedClassNames,
  align = "center",
  description,
  ...props
}: ExplanationDemoProps) {
  const [previewKey, setPreviewKey] = React.useState(0)

  const Preview = React.useMemo(() => {
    const Component = registry[name]?.component

    if (!Component) {
      return (
        <p
          data-algolia-ignore
          className="text text-muted-foreground justify-center items-center flex w-full h-full whitespace-pre"
        >
          Component{" "}
          <code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm whitespace-pre">
            {name}
          </code>{" "}
          not found.
        </p>
      )
    }

    return <Component />
  }, [name])

  return (
    <div
      data-algolia-ignore
      className={cn(
        "group relative my-8 flex flex-col h-full w-full",
        className
      )}
      {...props}
    >
      <div className="border border-border flex rounded-2xl">
        <div className="w-full flex items-center justify-center rounded-2xl min-h-[530px] overflow-hidden relative max-h-[620px]">
          <React.Suspense
            fallback={
              <div className="flex items-center justify-center w-full h-full text-sm text-muted-foreground">
                <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
                Loading...
              </div>
            }
          >
            <React.Fragment key={previewKey}>{Preview}</React.Fragment>
          </React.Suspense>
        </div>
      </div>
    </div>
  )
}
src/components/footer.tsx
파일 저장

import { ExternalLinkIcon } from "lucide-react";

export function Footer() {
  return (
    <footer className="flex items-center justify-center w-full h-20 mb-4">
      <div className="flex w-full mx-4 rounded-2xl bg-background items-center justify-center h-full border border-border">
        <div className="flex items-center justify-center mx-4 ">
          <p>
            built with 💙 by{" "}
            <a
              href="https://twitter.com/nonzeroexitcode"
              className="cursor-pointer no-underline text-blue hover:text-blue-400 dark:hover:text-blue-300 dark:text-blue-400 duration-300 transition-[colors,text-color] ease-out inline-flex items-center font-medium focus-ring rounded-lg"
            >
              nonzeroexitcode
              <ExternalLinkIcon
                className="ml-1 mt-0.5"
                size={14}
                strokeWidth={2.5}
              />
            </a>
          </p>
        </div>
      </div>
    </footer>
  )
}
src/components/header.tsx
파일 저장

"use client"

import { MainNav } from "./main-nav"
import { MobileNav } from "./mobile-nav"

export function Header() {
  return (
    <header className=" w-full flex justify-center items-center">
      <div className="h-20 px-4 z-50 flex mx-4 mt-4 bg-background flex-row w-full space-x-2 items-center rounded-2xl  border-border border ">
        <MobileNav />
        <MainNav />
      </div>
    </header>
  )
}
src/components/icons.tsx
파일 저장

import { Icon } from "next/dist/lib/metadata/types/metadata-types"

type IconProps = React.HTMLAttributes<SVGElement>

export const Icons = {
  twitter: (props: IconProps) => (
    <svg
      {...props}
      height="23"
      viewBox="0 0 1200 1227"
      width="23"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z" />
    </svg>
  ),
  gitHub: (props: IconProps) => (
    <svg viewBox="0 0 438.549 438.549" {...props}>
      <path
        fill="currentColor"
        d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
      ></path>
    </svg>
  ),
  radix: (props: IconProps) => (
    <svg viewBox="0 0 25 25" fill="none" {...props}>
      <path
        d="M12 25C7.58173 25 4 21.4183 4 17C4 12.5817 7.58173 9 12 9V25Z"
        fill="currentcolor"
      ></path>
      <path d="M12 0H4V8H12V0Z" fill="currentcolor"></path>
      <path
        d="M17 8C19.2091 8 21 6.20914 21 4C21 1.79086 19.2091 0 17 0C14.7909 0 13 1.79086 13 4C13 6.20914 14.7909 8 17 8Z"
        fill="currentcolor"
      ></path>
    </svg>
  ),
  aria: (props: IconProps) => (
    <svg role="img" viewBox="0 0 24 24" fill="currentColor" {...props}>
      <path d="M13.966 22.624l-1.69-4.281H8.122l3.892-9.144 5.662 13.425zM8.884 1.376H0v21.248zm15.116 0h-8.884L24 22.624Z" />
    </svg>
  ),
  npm: (props: IconProps) => (
    <svg viewBox="0 0 24 24" {...props}>
      <path
        d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"
        fill="currentColor"
      />
    </svg>
  ),
  yarn: (props: IconProps) => (
    <svg viewBox="0 0 24 24" {...props}>
      <path
        d="M12 0C5.375 0 0 5.375 0 12s5.375 12 12 12 12-5.375 12-12S18.625 0 12 0zm.768 4.105c.183 0 .363.053.525.157.125.083.287.185.755 1.154.31-.088.468-.042.551-.019.204.056.366.19.463.375.477.917.542 2.553.334 3.605-.241 1.232-.755 2.029-1.131 2.576.324.329.778.899 1.117 1.825.278.774.31 1.478.273 2.015a5.51 5.51 0 0 0 .602-.329c.593-.366 1.487-.917 2.553-.931.714-.009 1.269.445 1.353 1.103a1.23 1.23 0 0 1-.945 1.362c-.649.158-.95.278-1.821.843-1.232.797-2.539 1.242-3.012 1.39a1.686 1.686 0 0 1-.704.343c-.737.181-3.266.315-3.466.315h-.046c-.783 0-1.214-.241-1.45-.491-.658.329-1.51.19-2.122-.134a1.078 1.078 0 0 1-.58-1.153 1.243 1.243 0 0 1-.153-.195c-.162-.25-.528-.936-.454-1.946.056-.723.556-1.367.88-1.71a5.522 5.522 0 0 1 .408-2.256c.306-.727.885-1.348 1.32-1.737-.32-.537-.644-1.367-.329-2.21.227-.602.412-.936.82-1.08h-.005c.199-.074.389-.153.486-.259a3.418 3.418 0 0 1 2.298-1.103c.037-.093.079-.185.125-.283.31-.658.639-1.029 1.024-1.168a.94.94 0 0 1 .328-.06zm.006.7c-.507.016-1.001 1.519-1.001 1.519s-1.27-.204-2.266.871c-.199.218-.468.334-.746.44-.079.028-.176.023-.417.672-.371.991.625 2.094.625 2.094s-1.186.839-1.626 1.881c-.486 1.144-.338 2.261-.338 2.261s-.843.732-.899 1.487c-.051.663.139 1.2.343 1.515.227.343.51.176.51.176s-.561.653-.037.931c.477.25 1.283.394 1.71-.037.31-.31.371-1.001.486-1.283.028-.065.12.111.209.199.097.093.264.195.264.195s-.755.324-.445 1.066c.102.246.468.403 1.066.398.222-.005 2.664-.139 3.313-.296.375-.088.505-.283.505-.283s1.566-.431 2.998-1.357c.917-.598 1.293-.76 2.034-.936.612-.148.57-1.098-.241-1.084-.839.009-1.575.44-2.196.825-1.163.718-1.742.672-1.742.672l-.018-.032c-.079-.13.371-1.293-.134-2.678-.547-1.515-1.413-1.881-1.344-1.997.297-.5 1.038-1.297 1.334-2.78.176-.899.13-2.377-.269-3.151-.074-.144-.732.241-.732.241s-.616-1.371-.788-1.483a.271.271 0 0 0-.157-.046z"
        fill="currentColor"
      />
    </svg>
  ),
  pnpm: (props: IconProps) => (
    <svg viewBox="0 0 24 24" {...props}>
      <path
        d="M0 0v7.5h7.5V0zm8.25 0v7.5h7.498V0zm8.25 0v7.5H24V0zM8.25 8.25v7.5h7.498v-7.5zm8.25 0v7.5H24v-7.5zM0 16.5V24h7.5v-7.5zm8.25 0V24h7.498v-7.5zm8.25 0V24H24v-7.5z"
        fill="currentColor"
      />
    </svg>
  ),
  react: (props: IconProps) => (
    <svg viewBox="0 0 24 24" {...props}>
      <path
        d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"
        fill="currentColor"
      />
    </svg>
  ),
  tailwind: (props: IconProps) => (
    <svg viewBox="0 0 24 24" {...props}>
      <path
        d="M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z"
        fill="currentColor"
      />
    </svg>
  ),
  google: (props: IconProps) => (
    <svg role="img" viewBox="0 0 24 24" {...props}>
      <path
        fill="currentColor"
        d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
      />
    </svg>
  ),
  apple: (props: IconProps) => (
    <svg role="img" viewBox="0 0 24 24" {...props}>
      <path
        d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
        fill="currentColor"
      />
    </svg>
  ),
  paypal: (props: IconProps) => (
    <svg role="img" viewBox="0 0 24 24" {...props}>
      <path
        d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.565-5.946.36-1.847.174-3.388-.777-4.471z"
        fill="currentColor"
      />
    </svg>
  ),
  spinner: (props: IconProps) => (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      {...props}
    >
      <path d="M21 12a9 9 0 1 1-6.219-8.56" />
    </svg>
  ),
  framer: (props: IconProps) => (
    <svg
      fill="currentColor"
      width="24"
      height="24"
      viewBox="0 0 38 24"
      role="img"
      xmlns="http://www.w3.org/2000/svg"
    >
      <title>Framer icon</title>
      <path d="M4 0h16v8h-8zM4 8h8l8 8H4zM4 16h8v8z" />
    </svg>
  ),
  openai: (props: IconProps) => (
    <svg
      fill="currentColor"
      fill-rule="evenodd"
      height="1em"
      viewBox="0 0 24 24"
      width="1em"
      xmlns="http://www.w3.org/2000/svg"
      className="w-4 h-4 shrink-0"
    >
      <title>OpenAI</title>
      <path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path>
    </svg>
  ),
  anthropic: (props: IconProps) => (
    <svg
      fill="currentColor"
      fill-rule="evenodd"
      height="1em"
      viewBox="0 0 24 24"
      width="1em"
      xmlns="http://www.w3.org/2000/svg"
      className="w-4 h-4 shrink-0"
    >
      <title>Anthropic</title>
      <path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path>
    </svg>
  ),
}
src/components/install-tabs.tsx
파일 저장

"use client"

import React, { useState } from "react"
import { Highlight, PrismTheme } from "prism-react-renderer"

import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import theme from "@/styles/prism-theme.json"

import { CopyButton } from "./copy-button"

interface InstallTabsProps {
  command: string
  npx?: boolean
}

type PackageManager = "pnpm" | "npm" | "yarn" | "bun"

const packageManagers: Array<{ id: PackageManager; label: string }> = [
  { id: "pnpm", label: "pnpm" },
  { id: "npm", label: "npm" },
  { id: "yarn", label: "yarn" },
  { id: "bun", label: "bun" },
]

export const InstallTabs: React.FC<InstallTabsProps> = ({
  command,
  npx = false,
}) => {
  const [activeTab, setActiveTab] = useState<PackageManager>("pnpm")

  const getCommandPrefix = (pm: PackageManager): string => {
    if (npx) {
      switch (pm) {
        case "pnpm":
          return "pnpm dlx"
        case "npm":
          return "npx"
        case "yarn":
          return "npx"
        case "bun":
          return "bunx --bun"
        default:
          return "npx"
      }
    } else {
      switch (pm) {
        case "pnpm":
          return "pnpm add"
        case "npm":
          return "npm install"
        case "yarn":
          return "yarn add"
        case "bun":
          return "bun add"
        default:
          return "npm install"
      }
    }
  }

  const getFullCommand = (pm: PackageManager): string => {
    const prefix = getCommandPrefix(pm)
    return `${prefix} ${command}`
  }

  const handleCopy = async () => {
    const fullCommand = getFullCommand(activeTab)
    try {
      await navigator.clipboard.writeText(fullCommand)
    } catch (err) {
      console.warn("Copy failed:", err)
    }
  }

  return (
    <div className="border border-editor-border rounded-2xl overflow-hidden">
      <div className="flex items-center justify-between pl-4 pr-3 py-2 border-b border-editor-border bg-editor-background h-11">
        <Tabs
          value={activeTab}
          onValueChange={(value) => setActiveTab(value as PackageManager)}
          className="flex-1"
        >
          <TabsList className="bg-transparent h-auto p-0">
            {packageManagers.map((pm) => (
              <TabsTrigger
                key={pm.id}
                value={pm.id}
                className="text hover:text-white duration-300 ease-out transition px-2 py-1 h-auto data-[state=active]:bg-editor-background data-[state=active]:text-white text-muted-foreground cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white rounded"
                aria-label={pm.label}
              >
                {pm.label}
              </TabsTrigger>
            ))}
          </TabsList>
        </Tabs>
        <CopyButton onCopy={handleCopy} className='focus-visible:outline-2! focus-visible:outline-offset-2! focus-visible:outline-white! rounded!' />
      </div>
      <div className="bg-editor-background py-4">
        <Highlight
          theme={theme as PrismTheme}
          code={getFullCommand(activeTab)}
          language="js"
        >
          {({ className, style, tokens, getLineProps, getTokenProps }) => (
            <pre
              className={`${className} text-[13px] overflow-x-auto font-mono font-medium`}
              style={style}
            >
              {tokens.map((line, i) => (
                <div
                  key={i}
                  {...getLineProps({ line })}
                  className="flex items-center hover:bg-editor-border py-px px-4"
                >
                  <span className="mr-4 select-none text-muted-foreground text-right text-[10px] items-center flex">
                    1
                  </span>
                  <span>
                    {line.map((token, key) => (
                      <span key={key} {...getTokenProps({ token })} />
                    ))}
                  </span>
                </div>
              ))}
            </pre>
          )}
        </Highlight>
      </div>
    </div>
  )
}
src/components/landing/hero-images.tsx
파일 저장

"use client"

import Link from "next/link"

import { Component } from "@/lib/get-components"
import Floating, {
  FloatingElement,
} from "@/fancy/components/image/parallax-floating"

import HoverVideo from "./hover-video"

export function HeroImages({ allComps }: { allComps: Component[] }) {
  if (!Array.isArray(allComps)) {
    console.error("allComps is not an array:", allComps)
    return null
  }

  const getComp = (name: string) => {
    const comp = allComps.find((comp) => comp.name === name)
    if (!comp) {
      console.error(`Component ${name} not found`)
      return null
    }
    return comp
  }

  const preLink = "/docs/components"

  // Safely get component data with null checks
  const imageTrail = getComp("image-trail");
  const textHighlighter = getComp("text-highlighter");
  const gravity = getComp("gravity");
  const cssBox = getComp("css-box");
  const marqueePath = getComp("marquee-along-svg-path");

  if (!imageTrail || !textHighlighter || !gravity || !cssBox || !marqueePath) {
    console.error("One or more required components not found");
    return null;
  }

  return (
    <Floating sensitivity={-0.5} className="h-full">
      <FloatingElement
        depth={0.5}
        className="top-[15%] left-[2%] md:top-[25%] md:left-[5%] "
      >
        <Link 
          href={`${preLink}/blocks/image-trail`}
          className="-rotate-[3deg] inline-block rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
        >
          <HoverVideo
            thumbnail={imageTrail.thumbnail.url}
            videoSrc={imageTrail.demo.url}
            className="w-16 h-12 sm:w-24 sm:h-16 md:w-28 md:h-20 lg:w-32 lg:h-24 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform shadow-2xl rounded-xl"
            delay={0.5}
          />
        </Link>
      </FloatingElement>
      <FloatingElement
        depth={1}
        className="top-[0%] left-[8%] md:top-[6%] md:left-[11%]"
      >
        <Link 
          href={`${preLink}/text/text-highlighter`}
          className="inline-block rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
        >
          <HoverVideo
            thumbnail={textHighlighter.thumbnail.url}
            videoSrc={textHighlighter.demo.url}
            className="w-40 h-28 sm:w-48 sm:h-36 md:w-56 md:h-44 lg:w-60 lg:h-48 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform shadow-2xl rounded-xl"
            delay={0.7}
          />
        </Link>
      </FloatingElement>

      <FloatingElement
        depth={4}
        className="top-[90%] left-[6%] md:top-[80%] md:left-[8%]"
      >
        <Link 
          href={`${preLink}/text/gravity`}
          className="-rotate-[4deg] inline-block rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
        >
          <HoverVideo
            thumbnail={gravity.thumbnail.url}
            videoSrc={gravity.demo.url}
            className="w-40 h-40 sm:w-48 sm:h-48 md:w-60 md:h-60 lg:w-64 lg:h-64 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform shadow-2xl rounded-xl"
            delay={0.9}
          />
        </Link>
      </FloatingElement>
      <FloatingElement
        depth={2}
        className="top-[0%] left-[87%] md:top-[2%] md:left-[83%]"
      >
        <Link 
          href={`${preLink}/blocks/css-box`}
          className="rotate-[6deg] inline-block rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
        >
          <HoverVideo
            thumbnail={cssBox.thumbnail.url}
            videoSrc={cssBox.demo.url}
            className="w-40 h-36 sm:w-48 sm:h-44 md:w-60 md:h-52 lg:w-64 lg:h-56 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform shadow-2xl rounded-xl"
            delay={1.1}
          />
        </Link>
      </FloatingElement>
      <FloatingElement
        depth={1}
        className="top-[78%] left-[83%] md:top-[68%] md:left-[83%]"
      >
        <Link 
          href={`${preLink}/blocks/marquee-along-svg-path`}
          className="rotate-[19deg] inline-block rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
        >
          <HoverVideo
            thumbnail={marqueePath.thumbnail.url}
            videoSrc={marqueePath.demo.url}
            className="w-44 h-44 sm:w-64 sm:h-64 md:w-72 md:h-72 lg:w-80 lg:h-80 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform shadow-2xl rounded-xl"
            delay={1.3}
          />
        </Link>
      </FloatingElement>
    </Floating>
  )
}
src/components/landing/hover-video.tsx
파일 저장

import { useState } from "react"
import { motion } from "motion/react"

interface HoverVideoProps {
  thumbnail: string // URL for the image
  videoSrc: string // URL for the video
  className?: string // Optional additional styling
  delay?: number // Optional delay for the animation
}

const HoverVideo: React.FC<HoverVideoProps> = ({
  thumbnail,
  videoSrc,
  className,
  delay = 0,
}) => {
  const [isHovered, setIsHovered] = useState(false)

  return (
    <motion.div
      className={`relative overflow-hidden ${className}`}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      animate={{ opacity: 1 }}
      initial={{ opacity: 0 }}
      transition={{ duration: 0.2, ease: "easeOut", delay: delay }}
    >
      {/* Thumbnail */}
      <motion.img
        src={thumbnail}
        alt="Thumbnail"
        className="absolute inset-0 w-full h-full object-cover"
        animate={{ opacity: isHovered ? 0 : 1 }}
        transition={{ duration: 0 }}
      />
      {/* Video */}
      <motion.video
        src={videoSrc}
        className="absolute inset-0 w-full h-full object-cover"
        autoPlay
        muted
        loop
        poster={thumbnail}
        playsInline
        animate={{ opacity: isHovered ? 1 : 0 }}
        transition={{ duration: 0 }}
      />
    </motion.div>
  )
}

export default HoverVideo
src/components/landing/landing-hero.tsx
파일 저장

"use client"

import Link from "next/link"
import { LayoutGroup, motion } from "motion/react"

import { Component } from "@/lib/get-components"
import TextRotate from "@/fancy/components/text/text-rotate"

import { HeroImages } from "./hero-images"

const MotionLink = motion.create(Link)

export function LandingHero({ allComps }: { allComps: Component[] | null }) {
  return (
    <section className="w-full h-screen overflow-hidden md:overflow-clip overscroll-none flex flex-col items-center justify-center relative">

      {allComps && <HeroImages allComps={allComps} />}

      <div className=" flex flex-col justify-center items-center w-[250px] sm:w-[300px] md:w-[500px] lg:w-[700px] z-50 pointer-events-auto">
        <motion.h1
          className="text-3xl sm:text-5xl md:text-7xl lg:text-8xl text-center w-full justify-center items-center flex-col flex whitespace-pre leading-tight font-calendas tracking-tight space-y-1 md:space-y-4"
          animate={{ opacity: 1, y: 0 }}
          initial={{ opacity: 0, y: 20 }}
          transition={{ duration: 0.2, ease: "easeOut", delay: 0.3 }}
        >
          <span>Make your </span>
          <LayoutGroup>
            <motion.span layout className="flex whitespace-pre">
              <motion.span
                layout
                className="flex whitespace-pre"
                transition={{ type: "spring", damping: 30, stiffness: 400 }}
              >
                website{" "}
              </motion.span>

              <TextRotate
                texts={[
                  "fancy",
                  "fun",
                  "lovely ♥",
                  "weird",
                  "🪩 funky",
                  "💃🕺",
                  "sexy",
                  "🕶️ cool",
                  "go 🚀",
                  "🔥🔥🔥",
                  "over-animated?",
                  "pop ✨",
                  "rock 🤘",
                ]}
                mainClassName="overflow-hidden pr-3 text-blue dark:text-blue-500 py-0 pb-2 md:pb-4 rounded-xl"
                staggerDuration={0.03}
                staggerFrom="last"
                rotationInterval={3000}
                transition={{ type: "spring", damping: 30, stiffness: 400 }}
              />
            </motion.span>
          </LayoutGroup>
        </motion.h1>
        <motion.p
          className="text-sm sm:text-lg md:text-xl lg:text-2xl text-center font-overused-grotesk pt-4 sm:pt-8 md:pt-10 lg:pt-12"
          animate={{ opacity: 1, y: 0 }}
          initial={{ opacity: 0, y: 20 }}
          transition={{ duration: 0.2, ease: "easeOut", delay: 0.5 }}
        >
          with a growing library of ready-to-use react components &
          microinteractions. free & open source.
        </motion.p>

        <div className="flex flex-row justify-center space-x-4 items-center mt-10 sm:mt-16 md:mt-20 lg:mt-20 text-xs">
          <MotionLink
            href="/docs/introduction"
            className="w-28 sm:w-32 md:w-36 lg:w-40 sm:text-base md:text-lg lg:text-xl font-medium tracking-tight text-background bg-foreground px-3 py-1.5 sm:px-4 sm:py-2 md:px-4 md:py-2 lg:px-5 lg:py-2.5 rounded-lg md:rounded-xl z-20 shadow-2xl whitespace-nowrap cursor-pointer inline-block text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-foreground"
            animate={{ opacity: 1, y: 0 }}
            initial={{ opacity: 0, y: 20 }}
            transition={{
              duration: 0.2,
              ease: "easeOut",
              delay: 0.7,
              scale: {
                duration: 0.2,
              },
            }}
            whileTap={{ scale: 0.95 }}
            whileHover={{
              scale: 1.05,
              transition: { type: "spring", damping: 30, stiffness: 400 },
            }}
          >
            Check docs <span className="font-serif ml-1">→</span>
          </MotionLink>
          <MotionLink
            href="https://github.com/danielpetho/fancy"
            className="w-28 sm:w-32 md:w-36 lg:w-40 sm:text-base md:text-lg lg:text-xl font-medium tracking-tight text-white bg-blue dark:bg-blue-500 px-3 py-1.5 sm:px-4 sm:py-2 md:px-4 md:py-2 lg:px-5 lg:py-2.5 rounded-lg md:rounded-xl z-20 shadow-2xl whitespace-nowrap cursor-pointer inline-block text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-blue"
            animate={{ opacity: 1, y: 0 }}
            initial={{ opacity: 0, y: 20 }}
            transition={{
              duration: 0.2,
              ease: "easeOut",
              delay: 0.7,
              scale: {
                duration: 0.2,
              },
            }}
            whileTap={{ scale: 0.95 }}
            whileHover={{
              scale: 1.05,
              transition: { type: "spring", damping: 30, stiffness: 400 },
            }}
          >
            ★ on GitHub
          </MotionLink>
        </div>
      </div>
    </section>
  )
}
src/components/main-nav.tsx
파일 저장

"use client"

import Link from "next/link"

import { Icons } from "@/components/icons"

import "@/styles/docsearch.css"

import { Search } from "./doc-search"
import ThemeSwitcher from "./theme-switcher"

export function MainNav() {
  return (
    <nav className="flex items-center justify-between w-full gap-x-4">
      <div className="flex flex-row items-center gap-x-12">
        <Link href="/" className="flex items-center gap-x-2 focus-primary">
          <p className=" text-2xl px-2 pb-1.5 tracking-tight font-calendas scale-y-[120%] align-text-top ">
            fancy components*
          </p>
        </Link>
        <div className="flex-row gap-x-8 text-lg  font-regular items-end hidden md:flex">
          <Link href="/docs/introduction" className="focus-primary">
            <span
              className={`inline-flex font-normal border-box after:content-[attr(data-text)] after:font-black after:pointer-none after:overflow-hidden after:select-none after:invisible after:h-0 duration-300 transition-all hover:font-semibold flex-col ease-out text-center`}
              data-text="Docs"
            >
              Docs
            </span>
          </Link>
          <Link href="/components" className="focus-primary">
            <span
              className={`inline-flex font-normal border-box after:content-[attr(data-text)] after:font-black after:pointer-none after:overflow-hidden after:select-none after:invisible after:h-0 duration-300 transition-all hover:font-semibold flex-col ease-out text-center`}
              data-text="Components"
            >
              Components
            </span>
          </Link>
        </div>
      </div>
      <div className="flex-row gap-x-4 sm:gap-x-8 text-xl font-regular flex items-center">
        <div className="hidden sm:block">
          <Search />
        </div>
        <a
          href="https://github.com/danielpetho/fancy"
          className="block lg:hidden focus-primary text-center"
        >
          <Icons.gitHub className="w-[18px] h-[18px]" />
        </a>
        <a
          href="https://github.com/danielpetho/fancy"
          className="hidden lg:block focus-primary"
        >
          <span
            className={`inline-flex font-normal border-box after:content-[attr(data-text)] after:font-black after:pointer-none after:overflow-hidden after:select-none after:invisible after:h-0 duration-300 transition-all hover:font-semibold flex-col ease-out text-center`}
            data-text="Github"
          >
            Github
          </span>
        </a>
        <ThemeSwitcher />
      </div>
    </nav>
  )
}
src/components/mobile-nav.tsx
파일 저장

"use client"

import * as React from "react"
import Link, { LinkProps } from "next/link"
import { useRouter } from "next/navigation"

import { docsConfig } from "@/config/docs"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"

export function MobileNav() {
  const [open, setOpen] = React.useState(false)

  return (
    <Sheet open={open} onOpenChange={setOpen}>
      <SheetTrigger asChild>
        <Button
          variant="ghost"
          className="mr-2 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 lg:hidden group"
        >
          <svg
            strokeWidth="1.5"
            viewBox="0 0 24 24"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
            className="h-5 w-5"
          >
            <path
              d="M3 5H11"
              stroke="currentColor" 
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="group-hover:[stroke-width:3] transition-all duration-300 ease-out"
            ></path>
            <path
              d="M3 12H16"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="group-hover:[stroke-width:3] transition-all duration-300 delay-100 ease-out"
            ></path>
            <path
              d="M3 19H21"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="group-hover:[stroke-width:3] transition-all duration-300 delay-200 ease-out"
            ></path>
          </svg>
          <span className="sr-only">Toggle Menu</span>
        </Button>
      </SheetTrigger>
      <SheetContent side="left" className="pr-0 m-4 rounded-2xl h-[calc(100vh-3rem)] w-4/5">
        <MobileLink
          href="/"
          className="flex items-center"
          onOpenChange={setOpen}
        >
          {/* <Icons.logo className="mr-2 h-4 w-4" /> */}
          <p className=" text-2xl tracking-tight font-calendas scale-y-[120%] ">
            fancy components*
          </p>
        </MobileLink>
        <ScrollArea className="my-8 h-[calc(100vh-8rem)] pb-24 pl-1">
          <div className="flex flex-col space-y-2">
            {docsConfig.map((item, index) => (
              <div key={index} className="flex flex-col space-y-2 pt-6">
                <h4 className="text-2xl font-medium ">
                  {item.title}{" "}
                  <span className="align-super text-sm">
                    {item.title !== "Getting Started"
                      ? `(${item.items?.length})`
                      : ""}
                  </span>
                </h4>
                {item?.items?.length &&
                  item.items.map((item) => (
                    <React.Fragment key={item.href}>
                      {!item.disabled &&
                        (item.href ? (
                          <MobileLink
                            href={item.href}
                            onOpenChange={setOpen}
                            className="text-base text-foreground/50"
                          >
                            {item.title}
                            {item.label && (
                              <span className="ml-2 rounded-md bg-primary-blue px-1.5 py-0.5 text-xs leading-none text-white no-underline group-hover:no-underline">
                                {item.label}
                              </span>
                            )}
                          </MobileLink>
                        ) : (
                          item.title
                        ))}
                    </React.Fragment>
                  ))}
              </div>
            ))}
          </div>
        </ScrollArea>
      </SheetContent>
    </Sheet>
  )
}

interface MobileLinkProps extends LinkProps {
  onOpenChange?: (open: boolean) => void
  children: React.ReactNode
  className?: string
}

function MobileLink({
  href,
  onOpenChange,
  className,
  children,
  ...props
}: MobileLinkProps) {
  const router = useRouter()
  return (
    <Link
      href={href}
      onClick={() => {
        router.push(href.toString())
        onOpenChange?.(false)
      }}
      className={cn(className)}
      {...props}
    >
      {children}
    </Link>
  )
}
src/components/open-in-v0.tsx
파일 저장

import { Button, ButtonProps } from "@/components/ui/button"
import { motion } from "framer-motion"

export function OpenInV0Button({
  url,
  variant = "outline",
  className = "h-8 gap-1 border px-3 text-xs"
}: {
  url: string
  variant?: ButtonProps["variant"]
  className?: string
}) {
  return (
    <Button
      aria-label="Open in v0"
      variant={variant}
      className={className}
      asChild
    >
      <motion.a
        href={`https://v0.dev/chat/api/open?url=${url}`}
        target="_blank"
        rel="noreferrer"
        whileTap={{ scale: 0.95 }}
      >
        Open in{" "}
        <svg
          viewBox="0 0 40 20"
          fill="none"
          xmlns="http://www.w3.org/2000/svg"
          className="h-5 w-5 text-current"
        >
          <path
            d="M23.3919 0H32.9188C36.7819 0 39.9136 3.13165 39.9136 6.99475V16.0805H36.0006V6.99475C36.0006 6.90167 35.9969 6.80925 35.9898 6.71766L26.4628 16.079C26.4949 16.08 26.5272 16.0805 26.5595 16.0805H36.0006V19.7762H26.5595C22.6964 19.7762 19.4788 16.6139 19.4788 12.7508V3.68923H23.3919V12.7508C23.3919 12.9253 23.4054 13.0977 23.4316 13.2668L33.1682 3.6995C33.0861 3.6927 33.003 3.68923 32.9188 3.68923H23.3919V0Z"
            fill="currentColor"
          ></path>
          <path
            d="M13.7688 19.0956L0 3.68759H5.53933L13.6231 12.7337V3.68759H17.7535V17.5746C17.7535 19.6705 15.1654 20.6584 13.7688 19.0956Z"
            fill="currentColor"
          ></path>
        </svg>
      </motion.a>
    </Button>
  )
}
src/components/restart-button.tsx
파일 저장

"use client"

import { RotateCw } from "lucide-react"

import { Button } from "@/components/ui/button"

export function RestartButton({ onRestart }: { onRestart: () => void }) {
  return (
      <Button
        variant="outline"
        size="icon"
        onClick={onRestart}
        className="h-8 w-8 active:scale-95 duration-300 ease-out transition-[scale,background-color,opacity] group/restart-button"
        aria-label="Restart demo"
      >
        <RotateCw
          className="h-4 w-4 group-hover/restart-button:rotate-45 duration-300 ease-out transition-transform"
        />
      </Button>
  )
}
src/components/sidebar-nav.tsx
파일 저장

"use client"

import Link from "next/link"
import { usePathname } from "next/navigation"
import { motion } from "motion/react"

import { SidebarNavItem } from "@/types/nav"
import { cn } from "@/lib/utils"

export interface DocsSidebarNavProps {
  items: SidebarNavItem[]
}

export function DocsSidebarNav({ items }: DocsSidebarNavProps) {
  const pathname = usePathname()

  return items.length ? (
    <div className="w-full h-full py-6">
      {items.map((item, index) => (
        <div key={index} className="mb-4 pb-3 border-black px-6">
          <h4 className="text-2xl font-medium mb-2">
            {item.title}{" "}
            <span className="align-super text-sm">
              {item.title !== "Getting Started"
                ? `(${item.items?.length})`
                : ""}
            </span>
          </h4>
          {item?.items?.length && (
            <DocsSidebarNavItems items={item.items} pathname={pathname} />
          )}
        </div>
      ))}
    </div>
  ) : null
}

interface NavItemProps {
  item: SidebarNavItem
  index: number
  pathname: string | null
}

function NavItem({ item, index, pathname }: NavItemProps) {
  const isActive = pathname === item.href

  return (
    <motion.p key={index}>
      <Link
        href={item.href ?? "#"}
        className="inline-block focus-primary"
        target={item.external ? "_blank" : ""}
        rel={item.external ? "noreferrer" : ""}
      >
        <motion.span
          initial={{ 
            fontVariationSettings: isActive ? "'wght' 500" : "'wght' 400", 
            color: isActive ? "var(--foreground)" : "hsl(var(--muted-foreground))" 
          }}
          whileHover={{ 
            fontVariationSettings: "'wght' 500", 
            color: "var(--foreground)", 
            transition: { duration: 0.3, ease: "easeOut" }
          }}
          animate={{
            fontVariationSettings: isActive ? "'wght' 500" : "'wght' 400",
            color: isActive ? "var(--foreground)" : "hsl(var(--muted-foreground))",
            transition: { duration: 0.3, ease: "easeOut" }
          }}
          className={cn(
            "inline-block no-underline duration-300 transition-colors ease-out",
            isActive && "text-foreground",
            !isActive && "text-muted-foreground",
            item.disabled && "opacity-60 cursor-not-allowed"
          )}
        >
          {item.title}
        </motion.span>
        {item.label && (
          <span className="ml-1 rounded-md bg-blue dark:bg-blue-500 px-1.5 py-0.5 text-[11px] leading-none text-white">
            {item.label}
          </span>
        )}
      </Link>
    </motion.p>
  )
}

interface DocsSidebarNavItemsProps {
  items: SidebarNavItem[]
  pathname: string | null
}

export function DocsSidebarNavItems({
  items,
  pathname,
}: DocsSidebarNavItemsProps) {
  return items?.length ? (
    <div className="flex flex-col space-y-2">
      {items.map((item, index) => (
        <NavItem key={index} item={item} index={index} pathname={pathname} />
      ))}
    </div>
  ) : null
}
src/components/theme-provider.tsx
파일 저장

"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
src/components/theme-switcher.tsx
파일 저장

"use client"

import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"

import { Button } from "./ui/button"

const ThemeSwitcher = () => {
  const { setTheme, resolvedTheme } = useTheme()

  return (
    <Button
      className="flex items-center justify-center w-8 h-8 hover:bg-transparent text-foreground relative group"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
      variant={"ghost"}
    >
      <Sun className="absolute h-[19px] w-[19px] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0 group-hover:[stroke-width:3px] duration-300 ease-out" />
      <Moon className="absolute h-[19px] w-[19px] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 group-hover:[stroke-width:3px] duration-300 ease-out" />
      <span className="sr-only">Toggle theme</span>
    </Button>
  )
}

export default ThemeSwitcher
src/components/toc.tsx
파일 저장

// @ts-nocheck
"use client"

import * as React from "react"

import { TableOfContents } from "@/lib/toc"
import { cn } from "@/lib/utils"
import { useMounted } from "@/hooks/use-mounted"
import { motion } from "motion/react"

interface TocProps {
  toc: TableOfContents
}

export function DashboardTableOfContents({ toc }: TocProps) {
  const itemIds = React.useMemo(
    () =>
      toc.items
        ? toc.items
            .flatMap((item) => [item.url, item?.items?.map((item) => item.url)])
            .flat()
            .filter(Boolean)
            .map((id) => id?.split("#")[1])
        : [],
    [toc]
  )
  const activeHeading = useActiveItem(itemIds)
  const mounted = useMounted()

  if (!toc?.items || !mounted) {
    return null
  }

  return (
    <div className="space-y-2 p-6">
      <p className="font-medium">On This Page</p>
      <Tree tree={toc} activeItem={activeHeading} />
    </div>
  )
}

function useActiveItem(itemIds: string[]) {
  const [activeId, setActiveId] = React.useState(null)

  React.useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id)
          }
        })
      },
      { rootMargin: `0% 0% -80% 0%` }
    )

    itemIds?.forEach((id) => {
      const element = document.getElementById(id)
      if (element) {
        observer.observe(element)
      }
    })

    return () => {
      itemIds?.forEach((id) => {
        const element = document.getElementById(id)
        if (element) {
          observer.unobserve(element)
        }
      })
    }
  }, [itemIds])

  return activeId
}

interface TreeProps {
  tree: TableOfContents
  level?: number
  activeItem?: string
}

function Tree({ tree, level = 1, activeItem }: TreeProps) {
  return tree?.items?.length && level < 3 ? (
    <ul className={cn("m-0 list-none", { "pl-3": level !== 1 })}>
      {tree.items.map((item, index) => {
        return (
          <li key={index} className={cn("mt-0 pt-1")}>
            <motion.a
              href={item.url}
              onClick={(e) => {
                e.preventDefault()
                document.querySelector(item.url)?.scrollIntoView({
                  behavior: "smooth",
                })
              }}
              initial={{ fontVariationSettings: "'wght' 400", color: item.url === `#${activeItem}` ? "var(--foreground)" : "hsl(var(--muted-foreground))"  }}
              whileHover={{ fontVariationSettings: "'wght' 500", color: "var(--foreground)", transition: {duration: 0.3, ease: "easeOut"}}}
              animate={{
                fontVariationSettings: item.url === `#${activeItem}` ? "'wght' 500" : "'wght' 400",
                color: item.url === `#${activeItem}` ? "var(--foreground)" : "hsl(var(--muted-foreground))",
                transition: {duration: 0.3, ease: "easeOut"}
              }}
              className={cn(
                "inline-block no-underline duration-300 transition-[color] ease-out focus-outline",
                item.url === `#${activeItem}`
                  ? "text-foreground"
                  : "text-muted-foreground"
              )}
            >
              {item.title}
            </motion.a>
            {item.items?.length ? (
              <Tree tree={item} level={level + 1} activeItem={activeItem} />
            ) : null}
          </li>
        )
      })}
    </ul>
  ) : null
}
src/components/ui/breadcrumb.tsx
파일 저장

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"

import { cn } from "@/lib/utils"

const Breadcrumb = React.forwardRef<
  HTMLElement,
  React.ComponentPropsWithoutRef<"nav"> & {
    separator?: React.ReactNode
  }
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"

const BreadcrumbList = React.forwardRef<
  HTMLOListElement,
  React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
  <ol
    ref={ref}
    className={cn(
      "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
      className
    )}
    {...props}
  />
))
BreadcrumbList.displayName = "BreadcrumbList"

const BreadcrumbItem = React.forwardRef<
  HTMLLIElement,
  React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
  <li
    ref={ref}
    className={cn("inline-flex items-center gap-1.5", className)}
    {...props}
  />
))
BreadcrumbItem.displayName = "BreadcrumbItem"

const BreadcrumbLink = React.forwardRef<
  HTMLAnchorElement,
  React.ComponentPropsWithoutRef<"a"> & {
    asChild?: boolean
  }
>(({ asChild, className, ...props }, ref) => {
  const Comp = asChild ? Slot : "a"

  return (
    <Comp
      ref={ref}
      className={cn("transition-colors hover:text-foreground", className)}
      {...props}
    />
  )
})
BreadcrumbLink.displayName = "BreadcrumbLink"

const BreadcrumbPage = React.forwardRef<
  HTMLSpanElement,
  React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
  <span
    ref={ref}
    role="link"
    aria-disabled="true"
    aria-current="page"
    className={cn("font-normal text-foreground", className)}
    {...props}
  />
))
BreadcrumbPage.displayName = "BreadcrumbPage"

const BreadcrumbSeparator = ({
  children,
  className,
  ...props
}: React.ComponentProps<"li">) => (
  <li
    role="presentation"
    aria-hidden="true"
    className={cn("[&>svg]:size-3.5", className)}
    {...props}
  >
    {children ?? <ChevronRight />}
  </li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"

const BreadcrumbEllipsis = ({
  className,
  ...props
}: React.ComponentProps<"span">) => (
  <span
    role="presentation"
    aria-hidden="true"
    className={cn("flex h-9 w-9 items-center justify-center", className)}
    {...props}
  >
    <MoreHorizontal className="h-4 w-4" />
    <span className="sr-only">More</span>
  </span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"

export {
  Breadcrumb,
  BreadcrumbList,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbPage,
  BreadcrumbSeparator,
  BreadcrumbEllipsis,
}
src/components/ui/button.tsx
파일 저장

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium focus-ring disabled:pointer-events-none disabled:opacity-50 hover:cursor-pointer transition-[colors,background-color] duration-300 ease-out",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-muted hover:text-accent-foreground duration-300 ease-out",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground ",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 rounded-lg py-2",
        sm: "h-9 rounded-lg px-3",
        lg: "h-11 rounded-lg px-8",
        icon: "h-10 w-10 rounded-lg",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }
src/components/ui/card.tsx
파일 저장

import * as React from "react"

import { cn } from "@/lib/utils"

const Card = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      "rounded-lg border bg-card text-card-foreground shadow-xs",
      className
    )}
    {...props}
  />
))
Card.displayName = "Card"

const CardHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex flex-col space-y-1.5 p-6", className)}
    {...props}
  />
))
CardHeader.displayName = "CardHeader"

const CardTitle = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
  <h3
    ref={ref}
    className={cn(
      "text-2xl font-semibold leading-none tracking-tight",
      className
    )}
    {...props}
  />
))
CardTitle.displayName = "CardTitle"

const CardDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
CardDescription.displayName = "CardDescription"

const CardContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"

const CardFooter = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex items-center p-6 pt-0", className)}
    {...props}
  />
))
CardFooter.displayName = "CardFooter"

export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
src/components/ui/collapsible.tsx
파일 저장

"use client"

import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"

const Collapsible = CollapsiblePrimitive.Root

const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger

const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent

export { Collapsible, CollapsibleTrigger, CollapsibleContent }
src/components/ui/dropdown-menu.tsx
파일 저장

"use client"

import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"

import { cn } from "@/lib/utils"

const DropdownMenu = DropdownMenuPrimitive.Root

const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger

const DropdownMenuGroup = DropdownMenuPrimitive.Group

const DropdownMenuPortal = DropdownMenuPrimitive.Portal

const DropdownMenuSub = DropdownMenuPrimitive.Sub

const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup

const DropdownMenuSubTrigger = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
    inset?: boolean
  }
>(({ className, inset, children, ...props }, ref) => (
  <DropdownMenuPrimitive.SubTrigger
    ref={ref}
    className={cn(
      "flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent",
      inset && "pl-8",
      className
    )}
    {...props}
  >
    {children}
    <ChevronRight className="ml-auto h-4 w-4" />
  </DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
  DropdownMenuPrimitive.SubTrigger.displayName

const DropdownMenuSubContent = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
  <DropdownMenuPrimitive.SubContent
    ref={ref}
    className={cn(
      "z-50 min-w-[8rem] overflow-hidden rounded-lg border  bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
      className
    )}
    {...props}
  />
))
DropdownMenuSubContent.displayName =
  DropdownMenuPrimitive.SubContent.displayName

const DropdownMenuContent = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
  <DropdownMenuPrimitive.Portal>
    <DropdownMenuPrimitive.Content
      ref={ref}
      sideOffset={sideOffset}
      className={cn(
        "z-50 min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-xl/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className
      )}
      {...props}
    />
  </DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName

const DropdownMenuItem = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
    inset?: boolean
  }
>(({ className, inset, ...props }, ref) => (
  <DropdownMenuPrimitive.Item
    ref={ref}
    className={cn(
      "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
      inset && "pl-8",
      className
    )}
    {...props}
  />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName

const DropdownMenuCheckboxItem = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
  <DropdownMenuPrimitive.CheckboxItem
    ref={ref}
    className={cn(
      "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
      className
    )}
    checked={checked}
    {...props}
  >
    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
      <DropdownMenuPrimitive.ItemIndicator>
        <Check className="h-4 w-4" />
      </DropdownMenuPrimitive.ItemIndicator>
    </span>
    {children}
  </DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
  DropdownMenuPrimitive.CheckboxItem.displayName

const DropdownMenuRadioItem = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
  <DropdownMenuPrimitive.RadioItem
    ref={ref}
    className={cn(
      "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
      className
    )}
    {...props}
  >
    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
      <DropdownMenuPrimitive.ItemIndicator>
        <Circle className="h-2 w-2 fill-current" />
      </DropdownMenuPrimitive.ItemIndicator>
    </span>
    {children}
  </DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName

const DropdownMenuLabel = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Label>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
    inset?: boolean
  }
>(({ className, inset, ...props }, ref) => (
  <DropdownMenuPrimitive.Label
    ref={ref}
    className={cn(
      "px-2 py-1.5 text-sm font-semibold",
      inset && "pl-8",
      className
    )}
    {...props}
  />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName

const DropdownMenuSeparator = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
  <DropdownMenuPrimitive.Separator
    ref={ref}
    className={cn("-mx-1 my-1 h-px bg-muted", className)}
    {...props}
  />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName

const DropdownMenuShortcut = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
  return (
    <span
      className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
      {...props}
    />
  )
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"

export {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuCheckboxItem,
  DropdownMenuRadioItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuGroup,
  DropdownMenuPortal,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuRadioGroup,
}
src/components/ui/scroll-area.tsx
파일 저장

"use client"

import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"

import { cn } from "@/lib/utils"

const ScrollArea = React.forwardRef<
  React.ElementRef<typeof ScrollAreaPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
  <ScrollAreaPrimitive.Root
    ref={ref}
    className={cn("relative", className)}
    {...props}
  >
    <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
      {children}
    </ScrollAreaPrimitive.Viewport>
    <ScrollBar />
    <ScrollAreaPrimitive.Corner />
  </ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName

const ScrollBar = React.forwardRef<
  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
  <ScrollAreaPrimitive.ScrollAreaScrollbar
    ref={ref}
    orientation={orientation}
    className={cn(
      "flex touch-none select-none transition-colors",
      orientation === "vertical" &&
        "h-full w-2.5 border-l border-l-transparent p-[1px]",
      orientation === "horizontal" &&
        "h-2.5 flex-col border-t border-t-transparent p-[1px]",
      className
    )}
    {...props}
  >
    <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
  </ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName

export { ScrollArea, ScrollBar }
src/components/ui/select.tsx
파일 저장

"use client"

import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"

import { cn } from "@/lib/utils"

const Select = SelectPrimitive.Root

const SelectGroup = SelectPrimitive.Group

const SelectValue = SelectPrimitive.Value

const SelectTrigger = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Trigger>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Trigger
    ref={ref}
    className={cn(
      "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
      className
    )}
    {...props}
  >
    {children}
    <SelectPrimitive.Icon asChild>
      <ChevronDown className="h-4 w-4 opacity-50" />
    </SelectPrimitive.Icon>
  </SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName

const SelectScrollUpButton = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.ScrollUpButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1",
      className
    )}
    {...props}
  >
    <ChevronUp className="h-4 w-4" />
  </SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName

const SelectScrollDownButton = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.ScrollDownButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1",
      className
    )}
    {...props}
  >
    <ChevronDown className="h-4 w-4" />
  </SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
  SelectPrimitive.ScrollDownButton.displayName

const SelectContent = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
  <SelectPrimitive.Portal>
    <SelectPrimitive.Content
      ref={ref}
      className={cn(
        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        position === "popper" &&
          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
        className
      )}
      position={position}
      {...props}
    >
      <SelectScrollUpButton />
      <SelectPrimitive.Viewport
        className={cn(
          "p-1",
          position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
        )}
      >
        {children}
      </SelectPrimitive.Viewport>
      <SelectScrollDownButton />
    </SelectPrimitive.Content>
  </SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName

const SelectLabel = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Label>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.Label
    ref={ref}
    className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
    {...props}
  />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName

const SelectItem = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Item
    ref={ref}
    className={cn(
      "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
      className
    )}
    {...props}
  >
    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
      <SelectPrimitive.ItemIndicator>
        <Check className="h-4 w-4" />
      </SelectPrimitive.ItemIndicator>
    </span>

    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
  </SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

const SelectSeparator = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Separator>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.Separator
    ref={ref}
    className={cn("-mx-1 my-1 h-px bg-muted", className)}
    {...props}
  />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName

export {
  Select,
  SelectGroup,
  SelectValue,
  SelectTrigger,
  SelectContent,
  SelectLabel,
  SelectItem,
  SelectSeparator,
  SelectScrollUpButton,
  SelectScrollDownButton,
}
src/components/ui/separator.tsx
파일 저장

"use client"

import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"

import { cn } from "@/lib/utils"

const Separator = React.forwardRef<
  React.ElementRef<typeof SeparatorPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
  (
    { className, orientation = "horizontal", decorative = true, ...props },
    ref
  ) => (
    <SeparatorPrimitive.Root
      ref={ref}
      decorative={decorative}
      orientation={orientation}
      className={cn(
        "shrink-0 bg-border",
        orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
        className
      )}
      {...props}
    />
  )
)
Separator.displayName = SeparatorPrimitive.Root.displayName

export { Separator }
src/components/ui/sheet.tsx
파일 저장

"use client"

import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

const Sheet = SheetPrimitive.Root

const SheetTrigger = SheetPrimitive.Trigger

const SheetClose = SheetPrimitive.Close

const SheetPortal = SheetPrimitive.Portal

const SheetOverlay = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <SheetPrimitive.Overlay
    className={cn(
      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
      className
    )}
    {...props}
    ref={ref}
  />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName

const sheetVariants = cva(
  "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
  {
    variants: {
      side: {
        top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
        bottom:
          "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
        left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
        right:
          "inset-y-0 right-0 h-full w-3/4  border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
      },
    },
    defaultVariants: {
      side: "right",
    },
  }
)

interface SheetContentProps
  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
    VariantProps<typeof sheetVariants> {}

const SheetContent = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Content>,
  SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
  <SheetPortal>
    <SheetOverlay />
    <SheetPrimitive.Content
      ref={ref}
      className={cn(sheetVariants({ side }), className)}
      {...props}
    >
      {children}
      <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </SheetPrimitive.Close>
    </SheetPrimitive.Content>
  </SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName

const SheetHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col space-y-2 text-center sm:text-left",
      className
    )}
    {...props}
  />
)
SheetHeader.displayName = "SheetHeader"

const SheetFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
      className
    )}
    {...props}
  />
)
SheetFooter.displayName = "SheetFooter"

const SheetTitle = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
  <SheetPrimitive.Title
    ref={ref}
    className={cn("text-lg font-semibold text-foreground", className)}
    {...props}
  />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName

const SheetDescription = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
  <SheetPrimitive.Description
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName

export {
  Sheet,
  SheetPortal,
  SheetOverlay,
  SheetTrigger,
  SheetClose,
  SheetContent,
  SheetHeader,
  SheetFooter,
  SheetTitle,
  SheetDescription,
}
src/components/ui/table.tsx
파일 저장

import * as React from "react"

import { cn } from "@/lib/utils"

const Table = React.forwardRef<
  HTMLTableElement,
  React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
  <div className="relative w-full overflow-auto">
    <table
      ref={ref}
      className={cn("w-full caption-bottom text-sm", className)}
      {...props}
    />
  </div>
))
Table.displayName = "Table"

const TableHeader = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"

const TableBody = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <tbody
    ref={ref}
    className={cn("[&_tr:last-child]:border-0", className)}
    {...props}
  />
))
TableBody.displayName = "TableBody"

const TableFooter = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <tfoot
    ref={ref}
    className={cn(
      "border-t bg-muted/50 font-medium last:[&>tr]:border-b-0",
      className
    )}
    {...props}
  />
))
TableFooter.displayName = "TableFooter"

const TableRow = React.forwardRef<
  HTMLTableRowElement,
  React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
  <tr
    ref={ref}
    className={cn(
      "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
      className
    )}
    {...props}
  />
))
TableRow.displayName = "TableRow"

const TableHead = React.forwardRef<
  HTMLTableCellElement,
  React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
  <th
    ref={ref}
    className={cn(
      "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
      className
    )}
    {...props}
  />
))
TableHead.displayName = "TableHead"

const TableCell = React.forwardRef<
  HTMLTableCellElement,
  React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
  <td
    ref={ref}
    className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
    {...props}
  />
))
TableCell.displayName = "TableCell"

const TableCaption = React.forwardRef<
  HTMLTableCaptionElement,
  React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
  <caption
    ref={ref}
    className={cn("mt-4 text-sm text-muted-foreground", className)}
    {...props}
  />
))
TableCaption.displayName = "TableCaption"

export {
  Table,
  TableHeader,
  TableBody,
  TableFooter,
  TableHead,
  TableRow,
  TableCell,
  TableCaption,
}
src/components/ui/tabs.tsx
파일 저장

"use client"

import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"

import { cn } from "@/lib/utils"

const Tabs = TabsPrimitive.Root

const TabsList = React.forwardRef<
  React.ElementRef<typeof TabsPrimitive.List>,
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
  <TabsPrimitive.List
    ref={ref}
    className={cn(
      "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
      className
    )}
    {...props}
  />
))
TabsList.displayName = TabsPrimitive.List.displayName

const TabsTrigger = React.forwardRef<
  React.ElementRef<typeof TabsPrimitive.Trigger>,
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
  <TabsPrimitive.Trigger
    ref={ref}
    className={cn(
      "inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-[colors,background-color,text-color] focus-visible:outline-hidden focus-visible:ring-0 focus-visible:outline-offset-2 focus-visible:outline-2 rounded-2xl focus-visible:outline-primary-blue disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground cursor-pointer",
      className
    )}
    {...props}
  />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName

const TabsContent = React.forwardRef<
  React.ElementRef<typeof TabsPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
  <TabsPrimitive.Content
    ref={ref}
    className={cn(
      "mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-0 focus-visible:outline-2  focus-visible:outline-offset-2 rounded-2xl focus-visible:outline-primary-blue",
      className
    )}
    {...props}
  />
))
TabsContent.displayName = TabsPrimitive.Content.displayName

export { Tabs, TabsList, TabsTrigger, TabsContent }
src/config/docs.ts
파일 저장

import { SidebarNavItem } from "@/types/nav"

export const docsConfig: SidebarNavItem[] = [
  {
    title: "Getting Started",
    items: [
      {
        title: "Introduction",
        href: "/docs/introduction",
        items: [],
      },
      {
        title: "Installation",
        href: "/docs/installation",
        items: [],
      },
      {
        title: "Components",
        href: "/components",
        items: [],
      },
      {
        title: "Changelog",
        href: "/docs/changelog",
        items: [],
      },
      {
        title: "llms.txt",
        href: "/llms.txt",
        items: [],
      }
    ],
  },

  {
    title: "Text",
    href: "/docs/components/text",
    items: [
      {
        title: "Letter Swap Hover",
        href: "/docs/components/text/letter-swap",
        items: [],
      },
      {
        title: "Letter 3D Swap",
        href: "/docs/components/text/letter-3d-swap",
        items: [],
      },
      {
        title: "Random Letter Swap Hover",
        href: "/docs/components/text/random-letter-swap",
        items: [],
      },
      {
        title: "Vertical Cut Reveal",
        href: "/docs/components/text/vertical-cut-reveal",
        items: [],
      },
      {
        title: "Text Rotate",
        href: "/docs/components/text/text-rotate",
        items: [],
      },
      {
        title: "Variable Font Hover By Letter",
        href: "/docs/components/text/variable-font-hover-by-letter",
        items: [],
      },
      {
        title: "Variable Font Hover By Random Letter",
        href: "/docs/components/text/variable-font-hover-by-random-letter",
        items: [],
      },
      {
        title: "Scroll And Swap",
        href: "/docs/components/text/scroll-and-swap",
        items: [],
      },
      {
        title: "Text Cursor Proximity",
        href: "/docs/components/text/text-cursor-proximity",
        items: [],
      },
      {
        title: "Variable Font & Cursor",
        href: "/docs/components/text/variable-font-and-cursor",
        items: [],
      },
      {
        title: "Variable Font Cursor Proximity",
        href: "/docs/components/text/variable-font-cursor-proximity",
        items: [],
      },
      {
        title: "Breathing Text",
        href: "/docs/components/text/breathing-text",
        items: [],
      },
      {
        title: "Underline Animation",
        href: "/docs/components/text/underline-animation",
        items: [],
      },
      {
        title: "Underline To Background",
        href: "/docs/components/text/underline-to-background",
        items: [],
      },
      {
        title: "Basic Number Ticker",
        href: "/docs/components/text/basic-number-ticker",
        items: [],
      },
      {
        title: "Typewriter",
        href: "/docs/components/text/typewriter",
        items: [],
      },
      {
        title: "Scramble Hover",
        href: "/docs/components/text/scramble-hover",
        items: [],
      },
      {
        title: "Text Highlighter",
        href: "/docs/components/text/text-highlighter",
        items: [],
      },
      {
        title: "Scramble In",
        href: "/docs/components/text/scramble-in",
        items: [],
      },
      {
        title: "Text Along Path",
        href: "/docs/components/text/text-along-path",
        items: [],
      }
    ],
  },
  {
    title: "Carousel",
    href: "/docs/components/carousel",
    items: [
      {
        title: "Box Carousel",
        href: "/docs/components/carousel/box-carousel",
        items: [],
      },
    ],
  },
  {
    title: "Background",
    href: "/docs/components/background",
    items: [
      {
        title: "Animated Gradient With SVG",
        href: "/docs/components/background/animated-gradient-svg",
        items: [],
      },
      {
        title: "Pixel Trail",
        href: "/docs/components/background/pixel-trail",
        items: [],
      },
    ],
  },
  {
    title: "Physics",
    href: "/docs/components/physics",
    items: [
      {
        title: "Elastic Line",
        href: "/docs/components/physics/elastic-line",
        items: [],
      },
      {
        title: "Gravity",
        href: "/docs/components/physics/gravity",
        items: [],
      },
      {
        title: "Cursor Attractor & Gravity",
        href: "/docs/components/physics/cursor-attractor-and-gravity",
        items: [],
      },
    ],
  },
  {
    title: "Image",
    href: "/docs/components/image",
    items: [
      {
        title: "Image Trail",
        href: "/docs/components/image/image-trail",
        items: [],
      },
      {
        title: "Parallax Floating",
        href: "/docs/components/image/parallax-floating",
        items: [],
      },
    ],
  },
  {
    title: "Filter",
    href: "/docs/components/filter",
    items: [
      {
        title: "Gooey SVG Filter",
        href: "/docs/components/filter/gooey-svg-filter",
        items: [],
      },
      {
        title: "Pixelate SVG Filter",
        href: "/docs/components/filter/pixelate-svg-filter",
        items: [],
      }
    ],
  },

  {
    title: "Blocks",
    href: "/docs/components/blocks",
    items: [
      {
        title: "Drag Elements",
        href: "/docs/components/blocks/drag-elements",
        items: [],
      },
      {
        title: "Circling Elements",
        href: "/docs/components/blocks/circling-elements",
        items: [],
      },
      {
        title: "Media Between Text",
        href: "/docs/components/blocks/media-between-text",
        items: [],
      },
      {
        title: "CSS Box",
        href: "/docs/components/blocks/css-box",
        items: [],
      },
      {
        title: "Screensaver",
        href: "/docs/components/blocks/screensaver",
        items: [],
      },
      {
        title: "Sticky Footer",
        href: "/docs/components/blocks/sticky-footer",
        items: [],
      },
      {
        title: "Float",
        href: "/docs/components/blocks/float",
        items: [],
      },
      {
        title: "Stacking Cards",
        href: "/docs/components/blocks/stacking-cards",
        items: [],
      },
      {
        title: "Simple Marquee",
        href: "/docs/components/blocks/simple-marquee",
        items: [],
      },
      {
        title: "Marquee Along SVG Path",
        href: "/docs/components/blocks/marquee-along-svg-path",
        items: [],
      },
      // {
      //   title: "Element Along SVG Path",
      //   href: "/docs/components/blocks/element-along-svg-path",
      //   items: [],
      //   label: "New"
      // }
    ],
  },
]
src/config/site.ts
파일 저장

export const siteConfig = {
  name: "Fancy Components",
  url: "https://fancycomponents.dev",
  ogImage: "https://fancycomponents.dev/og.jpg",
  description:
    "Ready to use, fancy React components to make the web fun again. Free & Open Source.",
  links: {
    twitter: "https://twitter.com/nonzeroexitcode",
    github: "https://github.com/danielpetho/fancy",
  },
}

export type SiteConfig = typeof siteConfig
src/fancy/components/background/animated-gradient-with-svg.tsx
파일 저장

"use client"

import React, { useMemo, useRef } from "react"

import { cn } from "@/lib/utils"
import { useDimensions } from "@/hooks/use-debounced-dimensions"

interface AnimatedGradientProps {
  colors: string[]
  speed?: number
  blur?: "light" | "medium" | "heavy"
}

const randomInt = (min: number, max: number) => {
  return Math.floor(Math.random() * (max - min + 1)) + min
}

const AnimatedGradient: React.FC<AnimatedGradientProps> = ({
  colors,
  speed = 5,
  blur = "light",
}) => {
  const containerRef = useRef<HTMLDivElement>(null)
  const dimensions = useDimensions(containerRef)

  const circleSize = useMemo(
    () => Math.max(dimensions.width, dimensions.height),
    [dimensions.width, dimensions.height]
  )

  const blurClass =
    blur === "light"
      ? "blur-2xl"
      : blur === "medium"
        ? "blur-3xl"
        : "blur-[100px]"

  return (
    <div ref={containerRef} className="absolute inset-0 overflow-hidden">
      <div className={cn(`absolute inset-0`, blurClass)}>
        {colors.map((color, index) => {
          const animationProps = {
            animation: `background-gradient ${speed}s infinite ease-in-out`,
            animationDuration: `${speed}s`,
            top: `${Math.random() * 50}%`,
            left: `${Math.random() * 50}%`,
            "--tx-1": Math.random() - 0.5,
            "--ty-1": Math.random() - 0.5,
            "--tx-2": Math.random() - 0.5,
            "--ty-2": Math.random() - 0.5,
            "--tx-3": Math.random() - 0.5,
            "--ty-3": Math.random() - 0.5,
            "--tx-4": Math.random() - 0.5,
            "--ty-4": Math.random() - 0.5,
          } as React.CSSProperties

          return (
            <svg
              key={index}
              className={cn("absolute", "animate-background-gradient")}
              width={circleSize * randomInt(0.5, 1.5)}
              height={circleSize * randomInt(0.5, 1.5)}
              viewBox="0 0 100 100"
              style={animationProps}
            >
              <circle cx="50" cy="50" r="50" fill={color} />
            </svg>
          )
        })}
      </div>
    </div>
  )
}

export default AnimatedGradient
src/fancy/components/background/pixel-trail.tsx
파일 저장

"use client"

import React, { useCallback, useMemo, useRef } from "react"
import { motion, useAnimationControls } from "motion/react"
import { v4 as uuidv4 } from "uuid"

import { cn } from "@/lib/utils"
import { useDimensions } from "@/hooks/use-dimensions"

interface PixelTrailProps {
  pixelSize: number // px
  fadeDuration?: number // ms
  delay?: number // ms
  className?: string
  pixelClassName?: string
}

const PixelTrail: React.FC<PixelTrailProps> = ({
  pixelSize = 20,
  fadeDuration = 500,
  delay = 0,
  className,
  pixelClassName,
}) => {
  const containerRef = useRef<HTMLDivElement>(null)
  const dimensions = useDimensions(containerRef)
  const trailId = useRef(uuidv4())

  const handleMouseMove = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      if (!containerRef.current) return

      const rect = containerRef.current.getBoundingClientRect()
      const x = Math.floor((e.clientX - rect.left) / pixelSize)
      const y = Math.floor((e.clientY - rect.top) / pixelSize)

      const pixelElement = document.getElementById(
        `${trailId.current}-pixel-${x}-${y}`
      )
      if (pixelElement) {
        const animatePixel = (pixelElement as any).__animatePixel
        if (animatePixel) animatePixel()
      }
    },
    [pixelSize]
  )

  const columns = useMemo(
    () => Math.ceil(dimensions.width / pixelSize),
    [dimensions.width, pixelSize]
  )
  const rows = useMemo(
    () => Math.ceil(dimensions.height / pixelSize),
    [dimensions.height, pixelSize]
  )

  return (
    <div
      ref={containerRef}
      className={cn(
        "absolute inset-0 w-full h-full pointer-events-auto",
        className
      )}
      onMouseMove={handleMouseMove}
    >
      {Array.from({ length: rows }).map((_, rowIndex) => (
        <div key={rowIndex} className="flex">
          {Array.from({ length: columns }).map((_, colIndex) => (
            <PixelDot
              key={`${colIndex}-${rowIndex}`}
              id={`${trailId.current}-pixel-${colIndex}-${rowIndex}`}
              size={pixelSize}
              fadeDuration={fadeDuration}
              delay={delay}
              className={pixelClassName}
            />
          ))}
        </div>
      ))}
    </div>
  )
}

export default PixelTrail

interface PixelDotProps {
  id: string
  size: number
  fadeDuration: number
  delay: number
  className?: string
}

const PixelDot: React.FC<PixelDotProps> = React.memo(
  ({ id, size, fadeDuration, delay, className }) => {
    const controls = useAnimationControls()

    const animatePixel = useCallback(() => {
      controls.start({
        opacity: [1, 0],
        transition: { duration: fadeDuration / 1000, delay: delay / 1000 },
      })
    }, [])

    // Attach the animatePixel function to the DOM element
    const ref = useCallback(
      (node: HTMLDivElement | null) => {
        if (node) {
          ;(node as any).__animatePixel = animatePixel
        }
      },
      [animatePixel]
    )

    return (
      <motion.div
        id={id}
        ref={ref}
        className={cn("cursor-pointer-none", className)}
        style={{
          width: `${size}px`,
          height: `${size}px`,
        }}
        initial={{ opacity: 0 }}
        animate={controls}
        exit={{ opacity: 0 }}
      />
    )
  }
)

PixelDot.displayName = "PixelDot"
src/fancy/components/blocks/circling-elements.tsx
파일 저장

"use client"

import { Children } from "react"
import { motion } from "motion/react"

import { cn } from "@/lib/utils"

type CirclingElementsProps = {
  children: React.ReactNode
  radius?: number
  duration?: number // in seconds
  easing?: string
  direction?: "normal" | "reverse"
  className?: string
  pauseOnHover?: boolean
}

const CirclingElements: React.FC<CirclingElementsProps> = ({
  children,
  radius = 100,
  duration = 10,
  easing = "linear",
  direction = "normal",
  className,
  pauseOnHover = false,
}) => {
  return (
    <div className={cn("relative z-0 group/circling", className)}>
      {Children.map(children, (child, index) => {
        const offset = (index * 360) / Children.count(children)

        const animationProps = {
          "--circling-duration": duration,
          "--circling-radius": radius,
          "--circling-offset": offset,
          "--circling-direction": direction === "reverse" ? -1 : 1,
          animation: `circling ${duration}s ${easing} infinite`,
          animationName: "circling",
          animationDuration: `${duration}s`,
          animationTimingFunction: easing,
          animationIterationCount: "infinite",
        } as React.CSSProperties

        return (
          <motion.div
            key={index}
            style={animationProps}
            className={cn(
              "transform-gpu animate-circling absolute -translate-x-1/2 -translate-y-1/2",
              pauseOnHover &&
                "group-hover/circling:![animation-play-state:paused]"
            )}
          >
            {child}
          </motion.div>
        )
      })}
    </div>
  )
}

export default CirclingElements
src/fancy/components/blocks/css-box.tsx
파일 저장

"use client"

import {
  forwardRef,
  ReactNode,
  useCallback,
  useEffect,
  useImperativeHandle,
  useRef,
} from "react"
import { motion, useMotionValue, useSpring, useTransform } from "motion/react"

import { cn } from "@/lib/utils"

interface FaceProps {
  transform: string
  className?: string
  showBackface?: boolean
  children?: ReactNode
  style?: React.CSSProperties
}

const CubeFace = ({
  transform,
  className,
  showBackface,
  children,
  style,
}: FaceProps) => (
  <div
    className={cn(
      "absolute",
      showBackface ? "backface-visible" : "backface-hidden",
      className
    )}
    style={{ transform, ...style }}
  >
    {children}
  </div>
)

interface CubeFaces {
  front?: ReactNode
  back?: ReactNode
  right?: ReactNode
  left?: ReactNode
  top?: ReactNode
  bottom?: ReactNode
}

export interface CSSBoxRef {
  showFront: () => void
  showBack: () => void
  showLeft: () => void
  showRight: () => void
  showTop: () => void
  showBottom: () => void
  rotateTo: (x: number, y: number) => void
  getCurrentRotation: () => { x: number; y: number }
}

interface CSSBoxProps extends React.HTMLProps<HTMLDivElement> {
  width: number
  height: number
  depth: number
  className?: string
  perspective?: number
  stiffness?: number
  damping?: number
  showBackface?: boolean
  faces?: CubeFaces
  draggable?: boolean
}

const CSSBox = forwardRef<CSSBoxRef, CSSBoxProps>(
  (
    {
      width,
      height,
      depth,
      className,
      perspective = 600,
      stiffness = 100,
      damping = 30,
      showBackface = false,
      faces = {},
      draggable = true,
      ...props
    },
    ref
  ) => {
    const isDragging = useRef(false)
    const startPosition = useRef({ x: 0, y: 0 })
    const startRotation = useRef({ x: 0, y: 0 })

    const baseRotateX = useMotionValue(0)
    const baseRotateY = useMotionValue(0)

    const springRotateX = useSpring(baseRotateX, {
      stiffness,
      damping,
      ...(isDragging.current ? { stiffness: stiffness / 2 } : {}),
    })
    const springRotateY = useSpring(baseRotateY, {
      stiffness,
      damping,
      ...(isDragging.current ? { stiffness: stiffness / 2 } : {}),
    })

    const currentRotation = useRef({ x: 0, y: 0 })

    useImperativeHandle(
      ref,
      () => ({
        showFront: () => {
          baseRotateX.set(0)
          baseRotateY.set(0)
        },
        showBack: () => {
          baseRotateX.set(0)
          baseRotateY.set(180)
        },
        showLeft: () => {
          baseRotateX.set(0)
          baseRotateY.set(-90)
        },
        showRight: () => {
          baseRotateX.set(0)
          baseRotateY.set(90)
        },
        showTop: () => {
          baseRotateX.set(-90)
          baseRotateY.set(0)
        },
        showBottom: () => {
          baseRotateX.set(90)
          baseRotateY.set(0)
        },
        rotateTo: (x: number, y: number) => {
          baseRotateX.set(x)
          baseRotateY.set(y)
        },

        getCurrentRotation: () => currentRotation.current,
      }),
      []
    )

    const transform = useTransform(
      [springRotateX, springRotateY],
      ([x, y]) =>
        `translateZ(-${depth / 2}px) rotateX(${x}deg) rotateY(${y}deg)`
    )
    const handleStart = useCallback(
      (e: React.MouseEvent | React.TouchEvent) => {
        if (!draggable) return
        isDragging.current = true
        const point = 'touches' in e ? e.touches[0] : e
        startPosition.current = { x: point.clientX, y: point.clientY }
        startRotation.current = {
          x: baseRotateX.get(),
          y: baseRotateY.get(),
        }
      },
      [draggable]
    )

    const handleMove = useCallback((e: MouseEvent | TouchEvent) => {
      if (!isDragging.current) return
      const point = 'touches' in e ? e.touches[0] : e
      const deltaX = point.clientX - startPosition.current.x
      const deltaY = point.clientY - startPosition.current.y
      baseRotateX.set(startRotation.current.x - deltaY / 2)
      baseRotateY.set(startRotation.current.y + deltaX / 2)
    }, [])

    const handleEnd = useCallback(() => {
      isDragging.current = false
    }, [])

    useEffect(() => {
      if (draggable) {
        window.addEventListener("mousemove", handleMove)
        window.addEventListener("mouseup", handleEnd)
        window.addEventListener("touchmove", handleMove)
        window.addEventListener("touchend", handleEnd)
        return () => {
          window.removeEventListener("mousemove", handleMove)
          window.removeEventListener("mouseup", handleEnd)
          window.removeEventListener("touchmove", handleMove)
          window.removeEventListener("touchend", handleEnd)
        }
      }
    }, [draggable, handleMove, handleEnd])

    useEffect(() => {
      const unsubscribeX = baseRotateX.on("change", (v) => {
        currentRotation.current.x = v
      })
      const unsubscribeY = baseRotateY.on("change", (v) => {
        currentRotation.current.y = v
      })
      return () => {
        unsubscribeX()
        unsubscribeY()
      }
    }, [])

    return (
      <div
        className={cn(draggable && "cursor-move", className)}
        style={{
          width,
          height,
          perspective: `${perspective}px`,
        }}
        onMouseDown={handleStart}
        onTouchStart={handleStart}
        {...props}
      >
        <motion.div
          className="relative w-full h-full [transform-style:preserve-3d]"
          style={{ transform }}
        >
          {/* Front and Back */}
          <CubeFace
            transform={`rotateY(0deg) translateZ(${depth / 2}px)`}
            style={{ width, height }}
            showBackface={showBackface}
          >
            {faces.front}
          </CubeFace>

          <CubeFace
            transform={`rotateY(180deg) translateZ(${depth / 2}px)`}
            style={{ width, height }}
            showBackface={showBackface}
          >
            {faces.back}
          </CubeFace>

          {/* Right and Left */}
          <CubeFace
            transform={`rotateY(90deg) translateZ(${width / 2}px)`}
            style={{
              width: depth,
              height,
              left: (width - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.right}
          </CubeFace>

          <CubeFace
            transform={`rotateY(-90deg) translateZ(${width / 2}px)`}
            style={{
              width: depth,
              height,
              left: (width - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.left}
          </CubeFace>

          {/* Top and Bottom */}
          <CubeFace
            transform={`rotateX(90deg) translateZ(${height / 2}px)`}
            style={{
              width,
              height: depth,
              top: (height - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.top}
          </CubeFace>

          <CubeFace
            transform={`rotateX(-90deg) translateZ(${height / 2}px)`}
            style={{
              width,
              height: depth,
              top: (height - depth) / 2,
            }}
            showBackface={showBackface}
          >
            {faces.bottom}
          </CubeFace>
        </motion.div>
      </div>
    )
  }
)

CSSBox.displayName = "CSSBox"

export default CSSBox
src/fancy/components/blocks/drag-elements.tsx
파일 저장

"use client"

import React, { useEffect, useRef, useState } from "react"
import { InertiaOptions, motion } from "motion/react"

type DragElementsProps = {
  children: React.ReactNode
  dragElastic?:
    | number
    | { top?: number; left?: number; right?: number; bottom?: number }
    | boolean
  dragConstraints?:
    | { top?: number; left?: number; right?: number; bottom?: number }
    | React.RefObject<Element | null>
  dragMomentum?: boolean
  dragTransition?: InertiaOptions
  dragPropagation?: boolean
  selectedOnTop?: boolean
  className?: string
}

const DragElements: React.FC<DragElementsProps> = ({
  children,
  dragElastic = 0.5,
  dragConstraints,
  dragMomentum = true,
  dragTransition = { bounceStiffness: 200, bounceDamping: 300 },
  dragPropagation = true,
  selectedOnTop = true,
  className,
}) => {
  const constraintsRef = useRef<HTMLDivElement>(null)
  const [zIndices, setZIndices] = useState<number[]>([])

  const [isDragging, setIsDragging] = useState(false)

  useEffect(() => {
    setZIndices(
      Array.from({ length: React.Children.count(children) }, (_, i) => i)
    )
  }, [children])

  const bringToFront = (index: number) => {
    if (selectedOnTop) {
      setZIndices((prevIndices) => {
        const newIndices = [...prevIndices]
        const currentIndex = newIndices.indexOf(index)
        newIndices.splice(currentIndex, 1)
        newIndices.push(index)
        return newIndices
      })
    }
  }

  return (
    <div ref={constraintsRef} className={`relative w-full h-full ${className}`}>
      {React.Children.map(children, (child, index) => (
        <motion.div
          key={index}
          drag
          dragElastic={dragElastic}
          dragConstraints={dragConstraints || constraintsRef}
          dragMomentum={dragMomentum}
          dragTransition={dragTransition}
          dragPropagation={dragPropagation}
          style={{
            zIndex: zIndices.indexOf(index),

            cursor: isDragging ? "grabbing" : "grab",
          }}
          onDragStart={() => {
            bringToFront(index)
            setIsDragging(true)
          }}
          onDragEnd={() => setIsDragging(false)}
          whileDrag={{ cursor: "grabbing" }}
          className={"absolute"}
        >
          {child}
        </motion.div>
      ))}
    </div>
  )
}

export default DragElements
src/fancy/components/blocks/element-along-svg-path.tsx
파일 저장

import {
  createContext,
  RefObject,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react"
import {
  motion,
  MotionValue,
  useMotionValue,
  useScroll,
  UseScrollOptions,
  useSpring,
  useTime,
  useTransform,
} from "motion/react"

import { cn } from "@/lib/utils"

type PreserveAspectRatioAlign =
  | "none"
  | "xMinYMin"
  | "xMidYMin"
  | "xMaxYMin"
  | "xMinYMid"
  | "xMidYMid"
  | "xMaxYMid"
  | "xMinYMax"
  | "xMidYMax"
  | "xMaxYMax"

type PreserveAspectRatioMeetOrSlice = "meet" | "slice"

type PreserveAspectRatio =
  | PreserveAspectRatioAlign
  | `${Exclude<PreserveAspectRatioAlign, "none">} ${PreserveAspectRatioMeetOrSlice}`

interface ElementAlongPathProps {
  // Path properties
  path: string
  pathId?: string
  className?: string
  preserveAspectRatio?: PreserveAspectRatio
  showPath?: boolean
  direction?: "normal" | "reverse"

  // SVG properties
  width?: string | number
  height?: string | number
  viewBox?: string

  // Animation properties
  animationType?: "auto" | "scroll"

  // Animation properties if animationType is auto
  duration?: number
  transition?: any

  // Scroll animation properties if animationType is scroll
  scrollContainer?: RefObject<HTMLElement | null>
  scrollOffset?: UseScrollOptions["offset"]
  scrollTransformValues?: [number, number]

  // Children
  children?: React.ReactNode
}

interface ElementAlongPathItemProps {
  children: React.ReactNode
  className?: string
  startOffset?: number // 0-100 percentage
  transition?: any // Override parent transition
}

// Create context
const ElementAlongPathContext = createContext<{
  path: string
  animationType: "auto" | "scroll"
  direction: "normal" | "reverse" // Add direction to context
  progress: MotionValue<number>
  scrollYProgress: MotionValue<number>
  scrollTransformValues: [number, number]
  transition: any
  setHovered: (isHovered: boolean) => void
} | null>(null)

// Context hook
export const useElementAlongPathContext = () => {
  const context = useContext(ElementAlongPathContext)
  if (!context) {
    throw new Error("ElementAlongPathItem must be used within ElementAlongPath")
  }
  return context
}

// Item component
export const ElementAlongPathItem = ({
  children,
  className,
  startOffset = 0,
  transition: itemTransition,
}: ElementAlongPathItemProps) => {
  const {
    path,
    animationType,
    progress,
    scrollYProgress,
    scrollTransformValues,
    direction,
    transition: parentTransition,
    setHovered,
  } = useElementAlongPathContext()

  // Use item transition if provided, otherwise use parent transition
  const transition = itemTransition || parentTransition

  const initialOffset =
    direction === "normal" ? `${startOffset}%` : `${100 - startOffset}%`
  const animateOffset = direction === "normal" ? "100%" : "0%"

  const scp = useTransform(
    scrollYProgress,
    [0, 1],
    [scrollTransformValues[0], scrollTransformValues[1]]
  )

  return (
    <motion.div
      className={cn("absolute top-0 left-0", className)}
      initial={{ offsetDistance: initialOffset }}
      animate={{
        offsetDistance: animationType === "auto" ? animateOffset : undefined,
      }}
      style={{
        offsetPath: `path('${path}')`,
        offsetDistance: animationType === "scroll" ? scp : undefined,
      }}
      transition={transition}
      // onHoverStart={() => setHovered(true)}
      // onHoverEnd={() => setHovered(false)}
    >
      {children}
    </motion.div>
  )
}

const ElementAlongPath = ({
  children,
  // Path defaults
  path,
  pathId,
  preserveAspectRatio = "xMidYMid meet",
  showPath = false,
  className,

  // SVG defaults
  width = "100%",
  height = "100%",
  viewBox = "0 0 100 100",

  // Animation type
  animationType = "auto",
  direction = "normal",

  // Animation defaults
  duration = 4,
  transition = { duration: 4, repeat: Infinity, ease: "linear" },

  // Scroll animation defaults
  scrollContainer,
  scrollOffset = ["start end", "end end"],
  scrollTransformValues = [0, 100],
}: ElementAlongPathProps) => {
  const container = useRef<HTMLDivElement>(null)
  const [isHovered, setIsHovered] = useState(false)

  // Create a time scale factor that changes based on hover state
  const timeScale = useMotionValue(1)

  // Update time scale when hover state changes
  useEffect(() => {
    timeScale.set(isHovered ? 0.3 : 1) // Slow down to 30% speed when hovered
  }, [isHovered, timeScale])

  // Create a spring-based time scale for smooth transitions
  const smoothTimeScale = useSpring(timeScale, {
    stiffness: 100,
    damping: 30,
  })

  const t = useTime()
  const scaledTime = useTransform(t, (time) => time * smoothTimeScale.get())

  const progress = useTransform(
    scaledTime,
    [0, duration],
    direction === "normal" ? [0, 100] : [100, 0]
  )
  // naive id for the path. you should rather use yours :)
  const id =
    pathId || `animated-path-${Math.random().toString(36).substring(7)}`

  const { scrollYProgress } = useScroll({
    container: scrollContainer || container,
    offset: scrollOffset,
  })

  // Adjust scroll progress based on direction
  const scrollProgressValues =
    direction === "normal"
      ? [scrollTransformValues[0], scrollTransformValues[1]]
      : [scrollTransformValues[1], scrollTransformValues[0]]

  const scrollProgress = useTransform(
    scrollYProgress,
    [0, 1],
    scrollProgressValues
  )

  // Create the progress value based on animation type
  const finalProgress = animationType === "auto" ? progress : scrollProgress

  return (
    <ElementAlongPathContext.Provider
      value={{
        path,
        animationType,
        direction,
        progress: finalProgress,
        scrollYProgress,
        scrollTransformValues,
        transition: {
          ...transition,
        },
        setHovered: setIsHovered,
      }}
    >
      <div
        ref={container}
        className={cn("relative", className)}
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox={viewBox}
          width={width}
          height={height}
          preserveAspectRatio={preserveAspectRatio}
          className="w-full h-full"
        >
          <motion.path
            id={id}
            d={path}
            // initial={{ pathLength: 0.001 }}
            // animate={{ pathLength: 1 }}
            stroke={showPath ? "currentColor" : "none"}
            fill="none"
            transition={transition}
          />
        </svg>
        {children}
      </div>
    </ElementAlongPathContext.Provider>
  )
}

export default ElementAlongPath
src/fancy/components/blocks/float.tsx
파일 저장

"use client"

import React, { useRef } from "react"
import { motion, useAnimationFrame, useMotionValue } from "motion/react"

import { cn } from "@/lib/utils"

type FloatProps = {
  children: React.ReactNode
  speed?: number
  amplitude?: [number, number, number] // [x, y, z]
  rotationRange?: [number, number, number] // [x, y, z]
  timeOffset?: number
  className?: string
}

const Float: React.FC<FloatProps> = ({
  children,
  speed = 0.5,
  amplitude = [10, 30, 30], // Default [x, y, z] amplitudes
  rotationRange = [15, 15, 7.5], // Default [x, y, z] rotation ranges
  timeOffset = 0,
  className,
}) => {
  const x = useMotionValue(0)
  const y = useMotionValue(0)
  const z = useMotionValue(0)
  const rotateX = useMotionValue(0)
  const rotateY = useMotionValue(0)
  const rotateZ = useMotionValue(0)

  // Use refs for animation values to avoid recreating the animation frame callback
  const time = useRef(0)

  useAnimationFrame(() => {
    time.current += speed * 0.02

    // Smooth floating motion on all axes
    const newX = Math.sin(time.current * 0.7 + timeOffset) * amplitude[0]
    const newY = Math.sin(time.current * 0.6 + timeOffset) * amplitude[1]
    const newZ = Math.sin(time.current * 0.5 + timeOffset) * amplitude[2]

    // 3D rotations with different frequencies for more organic movement
    const newRotateX =
      Math.sin(time.current * 0.5 + timeOffset) * rotationRange[0]
    const newRotateY =
      Math.sin(time.current * 0.4 + timeOffset) * rotationRange[1]
    const newRotateZ =
      Math.sin(time.current * 0.3 + timeOffset) * rotationRange[2]

    x.set(newX)
    y.set(newY)
    z.set(newZ)
    rotateX.set(newRotateX)
    rotateY.set(newRotateY)
    rotateZ.set(newRotateZ)
  })

  return (
    <motion.div
      style={{
        x,
        y,
        z,
        rotateX,
        rotateY,
        rotateZ,
        transformStyle: "preserve-3d",
      }}
      className={cn("will-change-transform", className)}
    >
      {children}
    </motion.div>
  )
}

export default Float
src/fancy/components/blocks/marquee-along-svg-path.tsx
파일 저장

import React, { RefObject, useCallback, useEffect, useRef } from "react"
import {
  motion,
  SpringOptions,
  useAnimationFrame,
  useMotionValue,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react"

import { cn } from "@/lib/utils"

// Custom wrap function
const wrap = (min: number, max: number, value: number): number => {
  const range = max - min
  return ((((value - min) % range) + range) % range) + min
}

type PreserveAspectRatioAlign =
  | "none"
  | "xMinYMin"
  | "xMidYMin"
  | "xMaxYMin"
  | "xMinYMid"
  | "xMidYMid"
  | "xMaxYMid"
  | "xMinYMax"
  | "xMidYMax"
  | "xMaxYMax"

interface CSSVariableInterpolation {
  property: string
  from: number | string
  to: number | string
}

type PreserveAspectRatioMeetOrSlice = "meet" | "slice"

type PreserveAspectRatio =
  | PreserveAspectRatioAlign
  | `${Exclude<PreserveAspectRatioAlign, "none">} ${PreserveAspectRatioMeetOrSlice}`

interface MarqueeAlongSvgPathProps {
  children: React.ReactNode
  className?: string

  // Path properties
  path: string
  pathId?: string
  preserveAspectRatio?: PreserveAspectRatio
  showPath?: boolean

  // SVG properties
  width?: string | number
  height?: string | number
  viewBox?: string

  // Marquee properties
  baseVelocity?: number
  direction?: "normal" | "reverse"
  easing?: (value: number) => number
  slowdownOnHover?: boolean
  slowDownFactor?: number
  slowDownSpringConfig?: SpringOptions

  // Scroll properties
  useScrollVelocity?: boolean
  scrollAwareDirection?: boolean
  scrollSpringConfig?: SpringOptions
  scrollContainer?: RefObject<HTMLElement | null> | HTMLElement | null

  // Item repetition
  repeat?: number

  // Drag properties
  draggable?: boolean
  dragSensitivity?: number
  dragVelocityDecay?: number
  dragAwareDirection?: boolean
  grabCursor?: boolean

  // Z-index properties
  enableRollingZIndex?: boolean
  zIndexBase?: number
  zIndexRange?: number

  cssVariableInterpolation?: CSSVariableInterpolation[]

  // Responsive properties
  responsive?: boolean
}

const MarqueeAlongSvgPath = ({
  children,
  className,

  // Path defaults
  path,
  pathId,
  preserveAspectRatio = "xMidYMid meet",
  showPath = false,

  // SVG defaults
  width = "100%",
  height = "100%",
  viewBox = "0 0 100 100",

  // Marquee defaults
  baseVelocity = 5,
  direction = "normal",
  easing,
  slowdownOnHover = false,
  slowDownFactor = 0.3,
  slowDownSpringConfig = { damping: 50, stiffness: 400 },

  // Scroll defaults
  useScrollVelocity = false,
  scrollAwareDirection = false,
  scrollSpringConfig = { damping: 50, stiffness: 400 },
  scrollContainer,

  // Items repetition
  repeat = 3,

  // Drag defaults
  draggable = false,
  dragSensitivity = 0.2,
  dragVelocityDecay = 0.96,
  dragAwareDirection = false,
  grabCursor = false,

  // Z-index defaults
  enableRollingZIndex = true,
  zIndexBase = 1, // Base z-index value
  zIndexRange = 10, // Range of z-index values to use

  cssVariableInterpolation = [],

  // Responsive defaults
  responsive = false,
}: MarqueeAlongSvgPathProps) => {
  const container = useRef<HTMLDivElement>(null)
  const marqueeContainerRef = useRef<HTMLDivElement>(null)
  const baseOffset = useMotionValue(0)

  const pathRef = useRef<SVGPathElement>(null)

  const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())

  // Responsive scaling using direct DOM manipulation (no re-renders)
  useEffect(() => {
    if (!responsive) return

    const [, , vbWidth, vbHeight] = viewBox.split(" ").map(Number)
    const originalWidth = vbWidth || 100
    const originalHeight = vbHeight || 100

    const updateScale = () => {
      const wrapper = container.current
      const marqueeContainer = marqueeContainerRef.current
      if (!wrapper || !marqueeContainer) return

      const wrapperWidth = wrapper.clientWidth
      const wrapperHeight = wrapper.clientHeight

      const scaleX = wrapperWidth / originalWidth
      const scaleY = wrapperHeight / originalHeight
      const scale = Math.min(scaleX, scaleY)

      // Calculate the scaled dimensions
      const scaledWidth = originalWidth * scale
      const scaledHeight = originalHeight * scale

      // Center the marquee container within the wrapper
      const offsetX = (wrapperWidth - scaledWidth) / 2
      const offsetY = (wrapperHeight - scaledHeight) / 2

      // Set fixed dimensions on the container
      marqueeContainer.style.width = `${originalWidth}px`
      marqueeContainer.style.height = `${originalHeight}px`

      // Apply scale and position to center
      marqueeContainer.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`
      marqueeContainer.style.transformOrigin = "top left"
    }

    updateScale()
    window.addEventListener("resize", updateScale)
    return () => window.removeEventListener("resize", updateScale)
  }, [responsive, viewBox])

  // Create an array of items outside of the render function
  const items = React.useMemo(() => {
    const childrenArray = React.Children.toArray(children)

    return childrenArray.flatMap((child, childIndex) =>
      Array.from({ length: repeat }, (_, repeatIndex) => {
        const itemIndex = repeatIndex * childrenArray.length + childIndex
        const key = `${childIndex}-${repeatIndex}`
        return {
          child,
          childIndex,
          repeatIndex,
          itemIndex,
          key,
        }
      })
    )
  }, [children, repeat])

  // Function to calculate z-index based on offset distance
  const calculateZIndex = useCallback(
    (offsetDistance: number) => {
      if (!enableRollingZIndex) {
        return undefined
      }

      // Simple progress-based z-index
      const normalizedDistance = offsetDistance / 100
      return Math.floor(zIndexBase + normalizedDistance * zIndexRange)
    },
    [enableRollingZIndex, zIndexBase, zIndexRange]
  )

  // Generate a random ID for the path if not provided
  const id = pathId || `marquee-path-${Math.random().toString(36).substring(7)}`

  // Scroll tracking
  const { scrollY } = useScroll({
    container: (scrollContainer as RefObject<HTMLDivElement | null>) || container,
  })

  const scrollVelocity = useVelocity(scrollY)
  const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig)

  // Hover and drag state tracking
  const isHovered = useRef(false)
  const isDragging = useRef(false)
  const dragVelocity = useRef(0)

  // Direction factor for changing direction based on scroll or drag
  const directionFactor = useRef(direction === "normal" ? 1 : -1)

  // Motion values for animation
  const hoverFactorValue = useMotionValue(1)
  const defaultVelocity = useMotionValue(1)
  const smoothHoverFactor = useSpring(hoverFactorValue, slowDownSpringConfig)

  // Transform scroll velocity into a factor that affects marquee speed
  const velocityFactor = useTransform(
    useScrollVelocity ? smoothVelocity : defaultVelocity,
    [0, 1000],
    [0, 5],
    { clamp: false }
  )

  // Animation frame handler
  useAnimationFrame((_, delta) => {
    if (isDragging.current && draggable) {
      baseOffset.set(baseOffset.get() + dragVelocity.current)

      // Add decay to dragVelocity
      dragVelocity.current *= 0.9

      // Stop completely if velocity is very small
      if (Math.abs(dragVelocity.current) < 0.01) {
        dragVelocity.current = 0
      }

      return
    }

    // Update hover factor
    if (isHovered.current) {
      hoverFactorValue.set(slowdownOnHover ? slowDownFactor : 1)
    } else {
      hoverFactorValue.set(1)
    }

    // Calculate regular movement
    let moveBy =
      directionFactor.current *
      baseVelocity *
      (delta / 1000) *
      smoothHoverFactor.get()

    // Adjust movement based on scroll velocity if scrollAwareDirection is enabled
    if (scrollAwareDirection && !isDragging.current) {
      if (velocityFactor.get() < 0) {
        directionFactor.current = -1
      } else if (velocityFactor.get() > 0) {
        directionFactor.current = 1
      }
    }

    moveBy += directionFactor.current * moveBy * velocityFactor.get()

    if (draggable) {
      moveBy += dragVelocity.current

      // Update direction based on drag direction if dragAwareDirection is true
      if (dragAwareDirection && Math.abs(dragVelocity.current) > 0.1) {
        directionFactor.current = Math.sign(dragVelocity.current)
      }

      // Gradually decay drag velocity back to zero
      if (!isDragging.current && Math.abs(dragVelocity.current) > 0.01) {
        dragVelocity.current *= dragVelocityDecay
      } else if (!isDragging.current) {
        dragVelocity.current = 0
      }
    }

    baseOffset.set(baseOffset.get() + moveBy)
  })

  // Pointer event handlers for dragging
  const lastPointerPosition = useRef({ x: 0, y: 0 })

  const handlePointerDown = (e: React.PointerEvent) => {
    if (!draggable) return
    ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)

    if (grabCursor) {
      ;(e.currentTarget as HTMLElement).style.cursor = "grabbing"
    }

    isDragging.current = true
    lastPointerPosition.current = { x: e.clientX, y: e.clientY }

    // Pause automatic animation by setting velocity to 0
    dragVelocity.current = 0
  }

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!draggable || !isDragging.current) return

    const currentPosition = { x: e.clientX, y: e.clientY }

    // Calculate movement delta - simplified for path movement
    const deltaX = currentPosition.x - lastPointerPosition.current.x
    const deltaY = currentPosition.y - lastPointerPosition.current.y

    // For path following, we use a simple magnitude of movement
    const delta = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
    const projectedDelta = deltaX > 0 ? delta : -delta

    // Update drag velocity based on the projected movement
    dragVelocity.current = projectedDelta * dragSensitivity

    // Update last position
    lastPointerPosition.current = currentPosition
  }

  const handlePointerUp = (e: React.PointerEvent) => {
    if (!draggable) return
    ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
    isDragging.current = false

    if (grabCursor) {
      ;(e.currentTarget as HTMLElement).style.cursor = "grab"
    }
  }

  return (
    <div
      ref={container}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
      className={cn("relative", className)}
    >
      <div
        ref={marqueeContainerRef}
        className="relative"
        style={{ contain: "layout style" }}
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width={width}
          height={height}
          viewBox={viewBox}
          preserveAspectRatio={preserveAspectRatio}
          className="w-full h-full"
        >
          <path
            id={id}
            d={path}
            stroke={showPath ? "currentColor" : "none"}
            fill="none"
            ref={pathRef}
          />
        </svg>

        {items.map(({ child, repeatIndex, itemIndex, key }) => {
        // Create a unique offset transform for each item
        const itemOffset = useTransform(baseOffset, (v) => {
          const position = (itemIndex * 100) / items.length
          const wrappedValue = wrap(0, 100, v + position)
          return `${easing ? easing(wrappedValue / 100) * 100 : wrappedValue}%`
        })

        // Create a motion value for the current offset distance
        const currentOffsetDistance = useMotionValue(0)

        // Update z-index when offset distance changes
        const zIndex = useTransform(currentOffsetDistance, (value) =>
          calculateZIndex(value)
        )

        // Update current offset distance value when animation runs
        useEffect(() => {
          const unsubscribe = itemOffset.on("change", (value: string) => {
            // Parse percentage string to get numerical value
            const match = value.match(/^([\d.]+)%$/)
            if (match && match[1]) {
              currentOffsetDistance.set(parseFloat(match[1]))
            }
          })
          return unsubscribe
        }, [itemOffset, currentOffsetDistance])

        const cssVariables = Object.fromEntries(
          (cssVariableInterpolation || []).map(({ property, from, to }) => [
            property,
            useTransform(currentOffsetDistance, [0, 100], [from, to]),
          ])
        )

        return (
          <motion.div
            key={key}
            ref={(el) => {
              if (el) itemRefs.current.set(key, el)
            }}
            className={cn(
              "absolute top-0 left-0",
              draggable && grabCursor && "cursor-grab"
            )}
            style={{
              offsetPath: `path('${path}')`,
              offsetDistance: itemOffset,
              zIndex: enableRollingZIndex ? zIndex : undefined,
              willChange: "offset-distance",
              backfaceVisibility: "hidden",
              ...cssVariables,
            }}
            aria-hidden={repeatIndex > 0}
            onMouseEnter={() => (isHovered.current = true)}
            onMouseLeave={() => (isHovered.current = false)}
          >
            {child}
          </motion.div>
        )
      })}
      </div>
    </div>
  )
}

export default MarqueeAlongSvgPath
src/fancy/components/blocks/media-between-text.tsx
파일 저장

"use client"

import { ElementType, forwardRef, useImperativeHandle, useRef, useState } from "react"
import { motion, useInView, UseInViewOptions, Variants } from "motion/react"

import { cn } from "@/lib/utils"

interface MediaBetweenTextProps {
  /**
   * The text to display before the media
   */
  firstText: string

  /**
   * The text to display after the media
   */
  secondText: string

  /**
   * URL of the media (image or video) to display
   */
  mediaUrl: string

  /**
   * Type of media to display
   */
  mediaType: "image" | "video"

  /**
   * Optional class name for the media container
   */
  mediaContainerClassName?: string

  /**
   * Fallback URL for video poster or image loading
   */
  fallbackUrl?: string

  /**
   * HTML Tag to render the text elements as
   * @default p
   */
  as?: ElementType

  /**
   * Whether video should autoplay
   * @default true
   */
  autoPlay?: boolean

  /**
   * Whether video should loop
   * @default true
   */
  loop?: boolean

  /**
   * Whether video should be muted
   * @default true
   */
  muted?: boolean

  /**
   * Whether video should play inline
   * @default true
   */
  playsInline?: boolean

  /**
   * Alt text for image
   */
  alt?: string

  /**
   * Type of animation trigger
   * @default "hover"
   */
  triggerType?: "hover" | "ref" | "inView"

  /**
   * Reference to container element for inView trigger
   */
  containerRef?: React.RefObject<HTMLDivElement | null>

  /**
   * Options for useInView hook
   */
  useInViewOptionsProp?: UseInViewOptions

  /**
   * Custom animation variants
   */
  animationVariants?: {
    initial: Variants["initial"]
    animate: Variants["animate"]
  }

  /**
   * Optional class name for the root element
   */
  className?: string

  /**
   * Optional class name for the left text element
   */
  leftTextClassName?: string

  /**
   * Optional class name for the right text element
   */
  rightTextClassName?: string
}

export type MediaBetweenTextRef = {
  animate: () => void
  reset: () => void
}

export const MediaBetweenText = forwardRef<
  MediaBetweenTextRef,
  MediaBetweenTextProps
>(
  (
    {
      firstText,
      secondText,
      mediaUrl,
      mediaType,
      mediaContainerClassName,
      fallbackUrl,
      as = "p",
      autoPlay = true,
      loop = true,
      muted = true,
      playsInline = true,
      alt,
      triggerType = "hover",
      containerRef,
      useInViewOptionsProp = {
        once: true,
        amount: 0.5,
        root: containerRef,
      },
      animationVariants = {
        initial: { width: 0, opacity: 1 },
        animate: {
          width: "auto",
          opacity: 1,
          transition: { duration: 0.4, type: "spring", bounce: 0 },
        },
      },
      className,
      leftTextClassName,
      rightTextClassName,
    },
    ref
  ) => {
    const componentRef = useRef<HTMLDivElement>(null)
    const [isAnimating, setIsAnimating] = useState(false)

    const isInView =
      triggerType === "inView"
        ? useInView(componentRef || containerRef, useInViewOptionsProp)
        : false
    const [isHovered, setIsHovered] = useState(false)

    useImperativeHandle(ref, () => ({
      animate: () => setIsAnimating(true),
      reset: () => setIsAnimating(false),
    }))

    const shouldAnimate =
      triggerType === "hover"
        ? isHovered
        : triggerType === "inView"
          ? isInView
          : triggerType === "ref"
            ? isAnimating
            : false

    const TextComponent = motion.create(as)

    return (
      <div
        className={cn("flex", className)}
        ref={componentRef}
        onMouseEnter={() => triggerType === "hover" && setIsHovered(true)}
        onMouseLeave={() => triggerType === "hover" && setIsHovered(false)}
      >
        <TextComponent layout className={leftTextClassName}>
          {firstText}
        </TextComponent>
        <motion.div
          className={mediaContainerClassName}
          variants={animationVariants}
          initial="initial"
          animate={shouldAnimate ? "animate" : "initial"}
        >
          {mediaType === "video" ? (
            <video
              className="w-full h-full object-cover"
              autoPlay={autoPlay}
              loop={loop}
              muted={muted}
              playsInline={playsInline}
              poster={fallbackUrl}
            >
              <source src={mediaUrl} type="video/mp4" />
            </video>
          ) : (
            <img
              src={mediaUrl}
              alt={alt || `${firstText} ${secondText}`}
              className="w-full h-full object-cover"
            />
          )}
        </motion.div>
        <TextComponent layout className={rightTextClassName}>
          {secondText}
        </TextComponent>
      </div>
    )
  }
)

MediaBetweenText.displayName = "MediaBetweenText"

export default MediaBetweenText
src/fancy/components/blocks/screensaver.tsx
파일 저장

"use client"

import React, { useEffect, useRef } from "react"
import {
  motion,
  useAnimationFrame,
  useMotionValue,
} from "motion/react"

import { cn } from "@/lib/utils"
import { useDimensions } from "@/hooks/use-dimensions"

type ScreensaverProps = {
  children: React.ReactNode
  containerRef: React.RefObject<HTMLElement | null>
  speed?: number
  startPosition?: { x: number; y: number } // x,y as percentages (0-100)
  startAngle?: number // in degrees
  className?: string
}

const Screensaver: React.FC<ScreensaverProps> = ({
  children,
  speed = 3,
  startPosition = { x: 0, y: 0 },
  startAngle = 45,
  containerRef,
  className,
}) => {
  const elementRef = useRef<HTMLDivElement>(null)
  const x = useMotionValue(0)
  const y = useMotionValue(0)
  const angle = useRef((startAngle * Math.PI) / 180)

  const containerDimensions = useDimensions(containerRef)
  const elementDimensions = useDimensions(elementRef)

  // Set initial position based on container dimensions and percentage
  useEffect(() => {
    if (containerDimensions.width && containerDimensions.height) {
      const initialX =
        (startPosition.x / 100) *
        (containerDimensions.width - (elementDimensions.width || 0))
      const initialY =
        (startPosition.y / 100) *
        (containerDimensions.height - (elementDimensions.height || 0))
      x.set(initialX)
      y.set(initialY)
    }
  }, [containerDimensions, elementDimensions, startPosition])

  useAnimationFrame(() => {
    const velocity = speed
    const dx = Math.cos(angle.current) * velocity
    const dy = Math.sin(angle.current) * velocity

    let newX = x.get() + dx
    let newY = y.get() + dy

    // Check for collisions with container boundaries
    if (
      newX <= 0 ||
      newX + elementDimensions.width >= containerDimensions.width
    ) {
      angle.current = Math.PI - angle.current
      newX = Math.max(
        0,
        Math.min(newX, containerDimensions.width - elementDimensions.width)
      )
    }
    if (
      newY <= 0 ||
      newY + elementDimensions.height >= containerDimensions.height
    ) {
      angle.current = -angle.current
      newY = Math.max(
        0,
        Math.min(newY, containerDimensions.height - elementDimensions.height)
      )
    }

    x.set(newX)
    y.set(newY)
  })

  return (
    <motion.div
      ref={elementRef}
      style={{
        position: "absolute",
        top: 0,
        left: 0,
        x,
        y,
      }}
      className={cn("transform will-change-transform", className)}
    >
      {children}
    </motion.div>
  )
}

export default Screensaver
src/fancy/components/blocks/simple-carousel.tsx
파일 저장

import { RefObject, useRef } from "react"
import {
  motion,
  SpringOptions,
  useAnimationFrame,
  useMotionValue,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react"

import { cn } from "@/lib/utils"

// Custom wrap function
const wrap = (min: number, max: number, value: number): number => {
  const range = max - min
  return ((((value - min) % range) + range) % range) + min
}

interface SimpleMarqueeProps {
  children: React.ReactNode // The elements to be scrolled
  className?: string // Additional CSS classes for the container
  direction?: "left" | "right" | "up" | "down" // The direction of the marquee
  baseVelocity?: number // The base velocity of the marquee in pixels per second
  easing?: (value: number) => number // The easing function for the animation
  slowdownOnHover?: boolean // Whether to slow down the animation on hover
  slowDownFactor?: number // The factor to slow down the animation on hover
  slowDownSpringConfig?: SpringOptions // The spring config for the slow down animation
  useScrollVelocity?: boolean // Whether to use the scroll velocity to control the marquee speed
  scrollAwareDirection?: boolean // Whether to adjust the direction based on the scroll direction
  scrollSpringConfig?: SpringOptions // The spring config for the scroll velocity-based direction adjustment
  scrollContainer?: RefObject<HTMLElement | null> | HTMLElement | null // The container to use for the scroll velocity
  repeat?: number // The number of times to repeat the children.
  draggable?: boolean // Whether to allow dragging of the marquee
  dragSensitivity?: number // The sensitivity of the drag movement
  dragVelocityDecay?: number // The decay of the drag velocity. This means how fast the velocity will gradually reduce to baseVelocity when we release the drag
  dragAwareDirection?: boolean // Whether to adjust the direction based on the drag velocity
  dragAngle?: number // The angle of the drag movement in degrees. This is useful if you eg. rotating your marquee by 45 degrees
  grabCursor?: boolean // Whether to change the cursor to grabbing when dragging
}

const SimpleMarquee = ({
  children,
  className,
  direction = "right",
  baseVelocity = 5,
  slowdownOnHover = false,
  slowDownFactor = 0.3,
  slowDownSpringConfig = { damping: 50, stiffness: 400 },
  useScrollVelocity = false,
  scrollAwareDirection = false,
  scrollSpringConfig = { damping: 50, stiffness: 400 },
  scrollContainer,
  repeat = 3,
  draggable = false,
  dragSensitivity = 0.2,
  dragVelocityDecay = 0.96,
  dragAwareDirection = false,
  dragAngle = 0,
  grabCursor = false,
  easing,
}: SimpleMarqueeProps) => {
  const innerContainer = useRef<HTMLDivElement>(null)
  const baseX = useMotionValue(0)
  const baseY = useMotionValue(0)

  const { scrollY } = useScroll({
    container:
      (scrollContainer as RefObject<HTMLDivElement | null>) || innerContainer.current,
  })

  const scrollVelocity = useVelocity(scrollY)
  const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig)

  const hoverFactorValue = useMotionValue(1)
  const defaultVelocity = useMotionValue(1)

  // Track if user is currently dragging
  const isDragging = useRef(false)

  // Store drag velocity
  const dragVelocity = useRef(0)

  const smoothHoverFactor = useSpring(hoverFactorValue, slowDownSpringConfig)

  // Transform scroll velocity into a factor that affects marquee speed
  const velocityFactor = useTransform(
    useScrollVelocity ? smoothVelocity : defaultVelocity,
    [0, 1000],
    [0, 5],
    {
      clamp: false,
    }
  )

  // Determine if movement is horizontal or vertical.
  const isHorizontal = direction === "left" || direction === "right"

  // Convert baseVelocity to the correct direction
  const actualBaseVelocity =
    direction === "left" || direction === "up" ? -baseVelocity : baseVelocity

  // Reference to track if mouse is hovering
  const isHovered = useRef(false)

  // Direction factor for changing direction based on scroll or drag
  const directionFactor = useRef(1)

  // Transform baseX/baseY into a percentage for the transform
  // The wrap function ensures the value stays between 0 and -100
  const x = useTransform(baseX, (v) => {
    // Apply easing if provided, otherwise use linear (v directly)
    const wrappedValue = wrap(0, -100, v)
    return `${easing ? easing(wrappedValue / -100) * -100 : wrappedValue}%`
  })
  const y = useTransform(baseY, (v) => {
    // Apply easing if provided, otherwise use linear (v directly)
    const wrappedValue = wrap(0, -100, v)
    return `${easing ? easing(wrappedValue / -100) * -100 : wrappedValue}%`
  })

  useAnimationFrame((t, delta) => {
    if (isDragging.current && draggable) {
      if (isHorizontal) {
        baseX.set(baseX.get() + dragVelocity.current)
      } else {
        baseY.set(baseY.get() + dragVelocity.current)
      }

      // Add decay to dragVelocity when not moving
      // This will gradually reduce the velocity to zero when the pointer isn't moving
      dragVelocity.current *= 0.9

      // Stop completely if velocity is very small
      if (Math.abs(dragVelocity.current) < 0.01) {
        dragVelocity.current = 0
      }

      return
    }

    // Update hover factor
    if (isHovered.current) {
      hoverFactorValue.set(slowdownOnHover ? slowDownFactor : 1)
    } else {
      hoverFactorValue.set(1)
    }

    // Calculate regular movement
    let moveBy =
      directionFactor.current *
      actualBaseVelocity *
      (delta / 1000) *
      smoothHoverFactor.get()

    // Adjust movement based on scroll velocity if scrollAwareDirection is enabled
    if (scrollAwareDirection && !isDragging.current) {
      if (velocityFactor.get() < 0) {
        directionFactor.current = -1
      } else if (velocityFactor.get() > 0) {
        directionFactor.current = 1
      }
    }

    moveBy += directionFactor.current * moveBy * velocityFactor.get()

    if (draggable) {
      moveBy += dragVelocity.current

      // Update direction based on drag direction if dragAwareDirection is true
      if (dragAwareDirection && Math.abs(dragVelocity.current) > 0.1) {
        // If dragging in negative direction, set directionFactor to -1
        // If dragging in positive direction, set directionFactor to 1
        directionFactor.current = Math.sign(dragVelocity.current)
      }

      // Gradually decay drag velocity back to zero
      if (!isDragging.current && Math.abs(dragVelocity.current) > 0.01) {
        dragVelocity.current *= dragVelocityDecay
      } else if (!isDragging.current) {
        dragVelocity.current = 0
      }
    }

    if (isHorizontal) {
      baseX.set(baseX.get() + moveBy)
    } else {
      baseY.set(baseY.get() + moveBy)
    }
  })

  const lastPointerPosition = useRef({ x: 0, y: 0 })

  const handlePointerDown = (e: React.PointerEvent) => {
    if (!draggable)
      return // Capture the pointer to receive events even when pointer moves outside
    ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)

    if (grabCursor) {
      ;(e.currentTarget as HTMLElement).style.cursor = "grabbing"
    }

    isDragging.current = true
    lastPointerPosition.current = { x: e.clientX, y: e.clientY }

    // Pause automatic animation by setting velocity to 0
    dragVelocity.current = 0
  }

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!draggable || !isDragging.current) return

    const currentPosition = { x: e.clientX, y: e.clientY }

    // Calculate delta from last position
    const deltaX = currentPosition.x - lastPointerPosition.current.x
    const deltaY = currentPosition.y - lastPointerPosition.current.y

    // Convert dragAngle from degrees to radians
    const angleInRadians = (dragAngle * Math.PI) / 180

    // Calculate the projection of the movement along the angle direction
    // Using the dot product of the movement vector and the direction vector
    const directionX = Math.cos(angleInRadians)
    const directionY = Math.sin(angleInRadians)

    // Project the movement onto the angle direction
    const projectedDelta = deltaX * directionX + deltaY * directionY

    // Update drag velocity based on the projected movement
    dragVelocity.current = projectedDelta * dragSensitivity

    // Update last position
    lastPointerPosition.current = currentPosition
  }

  const handlePointerUp = (e: React.PointerEvent) => {
    if (!draggable) return // Release pointer capture
    ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)

    isDragging.current = false
  }

  return (
    <motion.div
      className={cn("flex", isHorizontal ? "flex-row" : "flex-col", className)}
      onHoverStart={() => (isHovered.current = true)}
      onHoverEnd={() => (isHovered.current = false)}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
      ref={innerContainer}
    >
      {Array.from({ length: repeat }, (_, i) => i).map((i) => (
        <motion.div
          key={i}
          className={cn(
            "shrink-0",
            isHorizontal && "flex",
            draggable && grabCursor && "cursor-grab"
          )}
          style={isHorizontal ? { x } : { y }}
          aria-hidden={i > 0}
        >
          {children}
        </motion.div>
      ))}
    </motion.div>
  )
}

export default SimpleMarquee
src/fancy/components/blocks/simple-marquee.tsx
파일 저장

import { RefObject, useRef } from "react"
import {
  motion,
  SpringOptions,
  useAnimationFrame,
  useMotionValue,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react"

import { cn } from "@/lib/utils"

// Custom wrap function
const wrap = (min: number, max: number, value: number): number => {
  const range = max - min
  return ((((value - min) % range) + range) % range) + min
}

interface SimpleMarqueeProps {
  children: React.ReactNode // The elements to be scrolled
  className?: string // Additional CSS classes for the container
  direction?: "left" | "right" | "up" | "down" // The direction of the marquee
  baseVelocity?: number // The base velocity of the marquee in pixels per second
  easing?: (value: number) => number // The easing function for the animation
  slowdownOnHover?: boolean // Whether to slow down the animation on hover
  slowDownFactor?: number // The factor to slow down the animation on hover
  slowDownSpringConfig?: SpringOptions // The spring config for the slow down animation
  useScrollVelocity?: boolean // Whether to use the scroll velocity to control the marquee speed
  scrollAwareDirection?: boolean // Whether to adjust the direction based on the scroll direction
  scrollSpringConfig?: SpringOptions // The spring config for the scroll velocity-based direction adjustment
  scrollContainer?: RefObject<HTMLElement | null> | HTMLElement | null // The container to use for the scroll velocity
  repeat?: number // The number of times to repeat the children.
  draggable?: boolean // Whether to allow dragging of the marquee
  dragSensitivity?: number // The sensitivity of the drag movement
  dragVelocityDecay?: number // The decay of the drag velocity. This means how fast the velocity will gradually reduce to baseVelocity when we release the drag
  dragAwareDirection?: boolean // Whether to adjust the direction based on the drag velocity
  dragAngle?: number // The angle of the drag movement in degrees. This is useful if you eg. rotating your marquee by 45 degrees
  grabCursor?: boolean // Whether to change the cursor to grabbing when dragging
}

const SimpleMarquee = ({
  children,
  className,
  direction = "right",
  baseVelocity = 5,
  slowdownOnHover = false,
  slowDownFactor = 0.3,
  slowDownSpringConfig = { damping: 50, stiffness: 400 },
  useScrollVelocity = false,
  scrollAwareDirection = false,
  scrollSpringConfig = { damping: 50, stiffness: 400 },
  scrollContainer,
  repeat = 3,
  draggable = false,
  dragSensitivity = 0.2,
  dragVelocityDecay = 0.96,
  dragAwareDirection = false,
  dragAngle = 0,
  grabCursor = false,
  easing,
}: SimpleMarqueeProps) => {
  const baseX = useMotionValue(0)
  const baseY = useMotionValue(0)

  const { scrollY } = useScroll({
    ...(scrollContainer && {
      container: scrollContainer as RefObject<HTMLDivElement>,
    }),
  })

  const scrollVelocity = useVelocity(scrollY)
  const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig)

  const hoverFactorValue = useMotionValue(1)
  const defaultVelocity = useMotionValue(1)

  // Track if user is currently dragging
  const isDragging = useRef(false)

  // Store drag velocity
  const dragVelocity = useRef(0)

  const smoothHoverFactor = useSpring(hoverFactorValue, slowDownSpringConfig)

  // Transform scroll velocity into a factor that affects marquee speed
  const velocityFactor = useTransform(
    useScrollVelocity ? smoothVelocity : defaultVelocity,
    [0, 1000],
    [0, 5],
    {
      clamp: false,
    }
  )

  // Determine if movement is horizontal or vertical.
  const isHorizontal = direction === "left" || direction === "right"

  // Convert baseVelocity to the correct direction
  const actualBaseVelocity =
    direction === "left" || direction === "up" ? -baseVelocity : baseVelocity

  // Reference to track if mouse is hovering
  const isHovered = useRef(false)

  // Direction factor for changing direction based on scroll or drag
  const directionFactor = useRef(1)

  // Transform baseX/baseY into a percentage for the transform
  // The wrap function ensures the value stays between 0 and -100
  const x = useTransform(baseX, (v) => {
    // Apply easing if provided, otherwise use linear (v directly)
    const wrappedValue = wrap(0, -100, v)
    return `${easing ? easing(wrappedValue / -100) * -100 : wrappedValue}%`
  })
  const y = useTransform(baseY, (v) => {
    // Apply easing if provided, otherwise use linear (v directly)
    const wrappedValue = wrap(0, -100, v)
    return `${easing ? easing(wrappedValue / -100) * -100 : wrappedValue}%`
  })

  useAnimationFrame((t, delta) => {
    if (isDragging.current && draggable) {
      if (isHorizontal) {
        baseX.set(baseX.get() + dragVelocity.current)
      } else {
        baseY.set(baseY.get() + dragVelocity.current)
      }

      // Add decay to dragVelocity when not moving
      // This will gradually reduce the velocity to zero when the pointer isn't moving
      dragVelocity.current *= 0.9

      // Stop completely if velocity is very small
      if (Math.abs(dragVelocity.current) < 0.01) {
        dragVelocity.current = 0
      }

      return
    }

    // Update hover factor
    if (isHovered.current) {
      hoverFactorValue.set(slowdownOnHover ? slowDownFactor : 1)
    } else {
      hoverFactorValue.set(1)
    }

    // Calculate regular movement
    let moveBy =
      directionFactor.current *
      actualBaseVelocity *
      (delta / 1000) *
      smoothHoverFactor.get()

    // Adjust movement based on scroll velocity if scrollAwareDirection is enabled
    if (scrollAwareDirection && !isDragging.current) {
      if (velocityFactor.get() < 0) {
        directionFactor.current = -1
      } else if (velocityFactor.get() > 0) {
        directionFactor.current = 1
      }
    }

    moveBy += directionFactor.current * moveBy * velocityFactor.get()

    if (draggable) {
      moveBy += dragVelocity.current

      // Update direction based on drag direction if dragAwareDirection is true
      if (dragAwareDirection && Math.abs(dragVelocity.current) > 0.1) {
        // If dragging in negative direction, set directionFactor to -1
        // If dragging in positive direction, set directionFactor to 1
        directionFactor.current = Math.sign(dragVelocity.current)
      }

      // Gradually decay drag velocity back to zero
      if (!isDragging.current && Math.abs(dragVelocity.current) > 0.01) {
        dragVelocity.current *= dragVelocityDecay
      } else if (!isDragging.current) {
        dragVelocity.current = 0
      }
    }

    if (isHorizontal) {
      baseX.set(baseX.get() + moveBy)
    } else {
      baseY.set(baseY.get() + moveBy)
    }
  })

  const lastPointerPosition = useRef({ x: 0, y: 0 })

  const handlePointerDown = (e: React.PointerEvent) => {
    if (!draggable)
      return // Capture the pointer to receive events even when pointer moves outside
    ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)

    if (grabCursor) {
      ;(e.currentTarget as HTMLElement).style.cursor = "grabbing"
    }

    isDragging.current = true
    lastPointerPosition.current = { x: e.clientX, y: e.clientY }

    // Pause automatic animation by setting velocity to 0
    dragVelocity.current = 0
  }

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!draggable || !isDragging.current) return

    const currentPosition = { x: e.clientX, y: e.clientY }

    // Calculate delta from last position
    const deltaX = currentPosition.x - lastPointerPosition.current.x
    const deltaY = currentPosition.y - lastPointerPosition.current.y

    // Convert dragAngle from degrees to radians
    const angleInRadians = (dragAngle * Math.PI) / 180

    // Calculate the projection of the movement along the angle direction
    // Using the dot product of the movement vector and the direction vector
    const directionX = Math.cos(angleInRadians)
    const directionY = Math.sin(angleInRadians)

    // Project the movement onto the angle direction
    const projectedDelta = deltaX * directionX + deltaY * directionY

    // Update drag velocity based on the projected movement
    dragVelocity.current = projectedDelta * dragSensitivity

    // Update last position
    lastPointerPosition.current = currentPosition
  }

  const handlePointerUp = (e: React.PointerEvent) => {
    if (!draggable) return // Release pointer capture
    ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)

    isDragging.current = false
  }

  return (
    <motion.div
      className={cn("flex", isHorizontal ? "flex-row" : "flex-col", className)}
      onHoverStart={() => (isHovered.current = true)}
      onHoverEnd={() => (isHovered.current = false)}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
    >
      {Array.from({ length: repeat }, (_, i) => i).map((i) => (
        <motion.div
          key={i}
          className={cn(
            "shrink-0",
            isHorizontal && "flex",
            draggable && grabCursor && "cursor-grab"
          )}
          style={isHorizontal ? { x } : { y }}
          aria-hidden={i > 0}
        >
          {children}
        </motion.div>
      ))}
    </motion.div>
  )
}

export default SimpleMarquee
src/fancy/components/blocks/stacking-cards.tsx
파일 저장

// author: Khoa Phan <https://www.pldkhoa.dev>

"use client"

import {
  createContext,
  useContext,
  useRef,
  type HTMLAttributes,
  type PropsWithChildren,
} from "react"
import {
  motion,
  useScroll,
  useTransform,
  type MotionValue,
  type UseScrollOptions,
} from "motion/react"

import { cn } from "@/lib/utils"

interface StackingCardsProps
  extends PropsWithChildren,
    HTMLAttributes<HTMLDivElement> {
  scrollOptions?: UseScrollOptions
  scaleMultiplier?: number
  totalCards: number
}

interface StackingCardItemProps
  extends HTMLAttributes<HTMLDivElement>,
    PropsWithChildren {
  index: number
  topPosition?: string
}

export default function StackingCards({
  children,
  className,
  scrollOptions,
  scaleMultiplier,
  totalCards,
  ...props
}: StackingCardsProps) {
  const targetRef = useRef<HTMLDivElement>(null)
  const { scrollYProgress } = useScroll({
    offset: ["start start", "end end"],
    ...scrollOptions,
    target: targetRef,
  })

  return (
    <StackingCardsContext.Provider
      value={{ progress: scrollYProgress, scaleMultiplier, totalCards }}
    >
      <div className={cn(className)} ref={targetRef} {...props}>
        {children}
      </div>
    </StackingCardsContext.Provider>
  )
}

const StackingCardItem = ({
  index,
  topPosition,
  className,
  children,
  ...props
}: StackingCardItemProps) => {
  const {
    progress,
    scaleMultiplier,
    totalCards = 0,
  } = useStackingCardsContext() // Get from Context
  const scaleTo = 1 - (totalCards - index) * (scaleMultiplier ?? 0.03)
  const rangeScale = [index * (1 / totalCards), 1]
  const scale = useTransform(progress, rangeScale, [1, scaleTo])
  const top = topPosition ?? `${5 + index * 3}%`

  return (
    <div className={cn("h-full sticky top-0", className)} {...props}>
      <motion.div
        className={"origin-top relative h-full"}
        style={{ top, scale }}
      >
        {children}
      </motion.div>
    </div>
  )
}

const StackingCardsContext = createContext<{
  progress: MotionValue<number>
  scaleMultiplier?: number
  totalCards?: number
} | null>(null)

export const useStackingCardsContext = () => {
  const context = useContext(StackingCardsContext)
  if (!context)
    throw new Error("StackingCardItem must be used within StackingCards")
  return context
}

export { StackingCardItem }
src/fancy/components/carousel/box-carousel.tsx
파일 저장

"use client"

import React, {
  forwardRef,
  memo,
  ReactNode,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from "react"
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  ValueAnimationOptions,
} from "motion/react"

import { cn } from "@/lib/utils"

interface CarouselItem {
  /**
   * Unique identifier for the carousel item
   */
  id: string
  /**
   * The type of media: "image" or "video"
   */
  type: "image" | "video"
  /**
   * Source URL for the image or video
   */
  src: string
  /**
   * (Optional) Alternative text for images
   */
  alt?: string
  /**
   * (Optional) Poster image for videos (displayed before playback)
   */
  poster?: string
}

/**
 * Props for a single face of the cube in the BoxCarousel.
 */
interface FaceProps {
  /**
   * The CSS transform string to position and rotate the face in 3D space.
   */
  transform: string
  /**
   * Optional additional CSS class names for the face.
   */
  className?: string
  /**
   * Optional React children to render inside the face.
   */
  children?: ReactNode
  /**
   * Optional inline styles for the face.
   */
  style?: React.CSSProperties
  /**
   * If true, enables debug mode (e.g., shows backface and opacity).
   */
  debug?: boolean
}

const CubeFace = memo(
  ({ transform, className, children, style, debug }: FaceProps) => (
    <div
      className={cn(
        "absolute overflow-hidden",
        debug && "backface-visible opacity-50",
        className
      )}
      style={{ transform, ...style }}
    >
      {children}
    </div>
  )
)

CubeFace.displayName = "CubeFace"

const MediaRenderer = memo(
  ({
    item,
    className,
    debug = false,
  }: {
    item: CarouselItem
    className?: string
    debug?: boolean
  }) => {
    if (!debug) {
      if (item.type === "video") {
        return (
          <video
            src={item.src}
            poster={item.poster}
            className={cn("w-full h-full object-cover", className)}
            muted
            loop
            autoPlay
          />
        )
      }

      return (
        <img
          src={item.src}
          alt={item.alt || ""}
          draggable={false}
          className={cn("w-full h-full object-cover", className)}
        />
      )
    }

    return (
      <div
        className={cn(
          "w-full h-full flex items-center justify-center border text-2xl",
          className
        )}
      >
        {item.id}
      </div>
    )
  }
)

MediaRenderer.displayName = "MediaRenderer"

export interface BoxCarouselRef {
  /**
   * Advance to the next item in the carousel.
   */
  next: () => void

  /**
   * Go back to the previous item in the carousel.
   */
  prev: () => void

  /**
   * Get the index of the currently visible item.
   */
  getCurrentItemIndex: () => number
}

type RotationDirection = "top" | "bottom" | "left" | "right"

interface SpringConfig {
  stiffness?: number
  damping?: number
  mass?: number
}

/**
 * Props for the BoxCarousel component
 */
interface BoxCarouselProps extends React.HTMLProps<HTMLDivElement> {
  /**
   * Array of items to display in the carousel
   */
  items: CarouselItem[]

  /**
   * Width of the carousel in pixels
   */
  width: number

  /**
   * Height of the carousel in pixels
   */
  height: number

  /**
   * Additional CSS classes for the container
   */
  className?: string

  /**
   * Enable debug mode (shows extra info/overlays)
   */
  debug?: boolean

  /**
   * Perspective value for 3D effect (in px)
   * @default 600
   */
  perspective?: number

  /**
   * The axis and direction of rotation
   * @default "vertical"
   * "top" | "bottom" | "left" | "right"
   */
  direction?: RotationDirection

  /**
   * Transition configuration for rotation animation
   * @default { duration: 1.25, ease: [0.953, 0.001, 0.019, 0.995] }
   */
  transition?: ValueAnimationOptions

  /**
   * Transition configuration for snapping after drag
   * @default { type: "spring", damping: 30, stiffness: 200 }
   */
  snapTransition?: ValueAnimationOptions

  /**
   * Spring physics config for drag interaction
   * @default { stiffness: 200, damping: 30 }
   */
  dragSpring?: SpringConfig

  /**
   * Enable auto-play mode
   * @default false
   */
  autoPlay?: boolean

  /**
   * Interval (ms) between auto-play transitions
   * @default 3000
   */
  autoPlayInterval?: number

  /**
   * Callback when the current item index changes
   */
  onIndexChange?: (index: number) => void

  /**
   * Enable drag interaction
   * @default true
   */
  enableDrag?: boolean

  /**
   * Sensitivity of drag (higher = more rotation per pixel)
   * @default 0.5
   */
  dragSensitivity?: number
}

const BoxCarousel = forwardRef<BoxCarouselRef, BoxCarouselProps>(
  (
    {
      items,
      width,
      height,
      className,
      perspective = 600,
      debug = false,
      direction = "left",
      transition = { duration: 1.25, ease: [0.953, 0.001, 0.019, 0.995] },
      snapTransition = { type: "spring", damping: 30, stiffness: 200 },
      dragSpring = { stiffness: 200, damping: 30 },
      autoPlay = false,
      autoPlayInterval = 3000,
      onIndexChange,
      enableDrag = true,
      dragSensitivity = 0.5,
      ...props
    },
    ref
  ) => {
    const [currentItemIndex, setCurrentItemIndex] = useState(0)
    const [currentFrontFaceIndex, setCurrentFrontFaceIndex] = useState(1)

    const prefersReducedMotion = useReducedMotion()

    const _transition = prefersReducedMotion ? { duration: 0 } : transition

    // 0 ⇢ will be shown if the user presses "prev"
    const [prevIndex, setPrevIndex] = useState(items.length - 1)

    // 1 ⇢ item that is currently visible
    const [currentIndex, setCurrentIndex] = useState(0)

    // 2 ⇢ will be shown on the next "next"
    const [nextIndex, setNextIndex] = useState(1)

    // 3 ⇢ two steps ahead (the face that is at the back right now)
    const [afterNextIndex, setAfterNextIndex] = useState(2)

    const [currentRotation, setCurrentRotation] = useState(0)

    const rotationCount = useRef(1)
    const isRotating = useRef(false)
    const pendingIndexChange = useRef<number | null>(null)
    const isDragging = useRef(false)
    const startPosition = useRef({ x: 0, y: 0 })
    const startRotation = useRef(0)

    const baseRotateX = useMotionValue(0)
    const baseRotateY = useMotionValue(0)

    // Use springs for smoother animation during drag
    const springRotateX = useSpring(baseRotateX, dragSpring)
    const springRotateY = useSpring(baseRotateY, dragSpring)

    const handleAnimationComplete = useCallback(
      (triggeredBy: string) => {
        if (isRotating.current && pendingIndexChange.current !== null) {
          isRotating.current = false

          let newFrontFaceIndex: number
          let currentBackFaceIndex: number

          if (triggeredBy === "next") {
            newFrontFaceIndex = (currentFrontFaceIndex + 1) % 4
            currentBackFaceIndex = (newFrontFaceIndex + 2) % 4
          } else {
            newFrontFaceIndex = (currentFrontFaceIndex - 1 + 4) % 4
            currentBackFaceIndex = (newFrontFaceIndex + 3) % 4
          }

          setCurrentItemIndex(pendingIndexChange.current)
          onIndexChange?.(pendingIndexChange.current)

          const indexOffset = triggeredBy === "next" ? 2 : -1

          if (currentBackFaceIndex === 0) {
            setPrevIndex(
              (pendingIndexChange.current + indexOffset + items.length) %
                items.length
            )
          } else if (currentBackFaceIndex === 1) {
            setCurrentIndex(
              (pendingIndexChange.current + indexOffset + items.length) %
                items.length
            )
          } else if (currentBackFaceIndex === 2) {
            setNextIndex(
              (pendingIndexChange.current + indexOffset + items.length) %
                items.length
            )
          } else if (currentBackFaceIndex === 3) {
            setAfterNextIndex(
              (pendingIndexChange.current + indexOffset + items.length) %
                items.length
            )
          }

          pendingIndexChange.current = null
          rotationCount.current++

          setCurrentFrontFaceIndex(newFrontFaceIndex)
        }
      },
      [currentFrontFaceIndex, items.length, onIndexChange]
    )

    // Drag functionality - using direct event handlers like css-box
    const handleDragStart = useCallback(
      (e: React.MouseEvent | React.TouchEvent) => {
        if (!enableDrag || isRotating.current) return

        isDragging.current = true
        const point = "touches" in e ? e.touches[0] : e
        startPosition.current = { x: point.clientX, y: point.clientY }
        startRotation.current = currentRotation

        // Prevent default to avoid text selection
        e.preventDefault()
      },
      [enableDrag, currentRotation]
    )

    const handleDragMove = useCallback(
      (e: MouseEvent | TouchEvent) => {
        if (!isDragging.current || isRotating.current) return

        const point = "touches" in e ? e.touches[0] : e
        const deltaX = point.clientX - startPosition.current.x
        const deltaY = point.clientY - startPosition.current.y

        const isVertical = direction === "top" || direction === "bottom"
        const delta = isVertical ? deltaY : deltaX
        const rotationDelta = (delta * dragSensitivity) / 2

        let newRotation = startRotation.current

        if (direction === "top" || direction === "right") {
          newRotation += rotationDelta
        } else {
          newRotation -= rotationDelta
        }

        // Constrain rotation to ±120 degrees from start position. Otherwise the index recalculation will be off. TBD - find a better solution
        const minRotation = startRotation.current - 120
        const maxRotation = startRotation.current + 120
        newRotation = Math.max(minRotation, Math.min(maxRotation, newRotation))

        // Apply the rotation immediately during drag
        if (isVertical) {
          baseRotateX.set(newRotation)
        } else {
          baseRotateY.set(newRotation)
        }
      },
      [enableDrag, direction, dragSensitivity]
    )

    const handleDragEnd = useCallback(() => {
      if (!isDragging.current) return

      isDragging.current = false

      const isVertical = direction === "top" || direction === "bottom"
      const currentValue = isVertical ? baseRotateX.get() : baseRotateY.get()

      // Calculate the nearest quarter rotation (90-degree increment)
      const quarterRotations = Math.round(currentValue / 90)
      const snappedRotation = quarterRotations * 90

      // Calculate how many steps we've moved from the original position
      const rotationDifference = snappedRotation - currentRotation
      const steps = Math.round(rotationDifference / 90)

      if (steps !== 0) {
        isRotating.current = true

        // Calculate new item index
        let newItemIndex = currentItemIndex
        for (let i = 0; i < Math.abs(steps); i++) {
          if (steps > 0) {
            newItemIndex = (newItemIndex + 1) % items.length
          } else {
            newItemIndex =
              newItemIndex === 0 ? items.length - 1 : newItemIndex - 1
          }
        }

        pendingIndexChange.current = newItemIndex

        // Animate to the snapped position
        const targetMotionValue = isVertical ? baseRotateX : baseRotateY
        animate(targetMotionValue, snappedRotation, {
          ...snapTransition,
          onComplete: () => {
            handleAnimationComplete(steps > 0 ? "next" : "prev")
            setCurrentRotation(snappedRotation)
          },
        })
      } else {
        // Snap back to current position
        const targetMotionValue = isVertical ? baseRotateX : baseRotateY
        animate(targetMotionValue, currentRotation, snapTransition)
      }
    }, [
      direction,
      baseRotateX,
      baseRotateY,
      currentRotation,
      currentItemIndex,
      items.length,
      transition,
      handleAnimationComplete,
    ])

    // Set up global event listeners for drag
    useEffect(() => {
      if (enableDrag) {
        window.addEventListener("mousemove", handleDragMove)
        window.addEventListener("mouseup", handleDragEnd)
        window.addEventListener("touchmove", handleDragMove)
        window.addEventListener("touchend", handleDragEnd)

        return () => {
          window.removeEventListener("mousemove", handleDragMove)
          window.removeEventListener("mouseup", handleDragEnd)
          window.removeEventListener("touchmove", handleDragMove)
          window.removeEventListener("touchend", handleDragEnd)
        }
      }
    }, [enableDrag, handleDragMove, handleDragEnd])

    const next = useCallback(() => {
      if (items.length === 0 || isRotating.current) return

      isRotating.current = true
      const newIndex = (currentItemIndex + 1) % items.length
      pendingIndexChange.current = newIndex

      if (direction === "top") {
        animate(baseRotateX, currentRotation + 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("next")
            setCurrentRotation(currentRotation + 90)
          },
        })
      } else if (direction === "bottom") {
        animate(baseRotateX, currentRotation - 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("next")
            setCurrentRotation(currentRotation - 90)
          },
        })
      } else if (direction === "left") {
        animate(baseRotateY, currentRotation - 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("next")
            setCurrentRotation(currentRotation - 90)
          },
        })
      } else if (direction === "right") {
        animate(baseRotateY, currentRotation + 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("next")
            setCurrentRotation(currentRotation + 90)
          },
        })
      }
    }, [items.length, direction, transition, currentRotation])

    const prev = useCallback(() => {
      if (items.length === 0 || isRotating.current) return

      isRotating.current = true
      const newIndex =
        currentItemIndex === 0 ? items.length - 1 : currentItemIndex - 1
      pendingIndexChange.current = newIndex

      if (direction === "top") {
        animate(baseRotateX, currentRotation - 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("prev")
            setCurrentRotation(currentRotation - 90)
          },
        })
      } else if (direction === "bottom") {
        animate(baseRotateX, currentRotation + 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("prev")
            setCurrentRotation(currentRotation + 90)
          },
        })
      } else if (direction === "left") {
        animate(baseRotateY, currentRotation + 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("prev")
            setCurrentRotation(currentRotation + 90)
          },
        })
      } else if (direction === "right") {
        animate(baseRotateY, currentRotation - 90, {
          ..._transition,
          onComplete: () => {
            handleAnimationComplete("prev")
            setCurrentRotation(currentRotation - 90)
          },
        })
      }
    }, [items.length, direction, transition])

    useImperativeHandle(
      ref,
      () => ({
        next,
        prev,
        getCurrentItemIndex: () => currentItemIndex,
      }),
      [next, prev, currentItemIndex]
    )

    const depth = useMemo(
      () => (direction === "top" || direction === "bottom" ? height : width),
      [direction, width, height]
    )

    const transform = useTransform(
      isDragging.current
        ? [springRotateX, springRotateY]
        : [baseRotateX, baseRotateY],
      ([x, y]) =>
        `translateZ(-${depth / 2}px) rotateX(${x}deg) rotateY(${y}deg)`
    )

    // Determine face transforms based on the desired rotation axis
    const faceTransforms = (() => {
      switch (direction) {
        case "left":
          return [
            // left, front, right, back (rotation around Y-axis)
            `rotateY(-90deg) translateZ(${width / 2}px)`,
            `rotateY(0deg) translateZ(${depth / 2}px)`,
            `rotateY(90deg) translateZ(${width / 2}px)`,
            `rotateY(180deg) translateZ(${depth / 2}px)`,
          ]
        case "top":
          return [
            // top, front, bottom, back (rotation around X-axis)
            `rotateX(90deg) translateZ(${height / 2}px)`,
            `rotateY(0deg) translateZ(${depth / 2}px)`,
            `rotateX(-90deg) translateZ(${height / 2}px)`,
            `rotateY(180deg) translateZ(${depth / 2}px) rotateZ(180deg)`,
          ]
        case "right":
          return [
            // right, front, left, back (rotation around Y-axis)
            `rotateY(90deg) translateZ(${width / 2}px)`,
            `rotateY(0deg) translateZ(${depth / 2}px)`,
            `rotateY(-90deg) translateZ(${width / 2}px)`,
            `rotateY(180deg) translateZ(${depth / 2}px)`,
          ]
        case "bottom":
          return [
            // bottom, front, top, back (rotation around X-axis)
            `rotateX(-90deg) translateZ(${height / 2}px)`,
            `rotateY(0deg) translateZ(${depth / 2}px)`,
            `rotateX(90deg) translateZ(${height / 2}px)`,
            `rotateY(180deg) translateZ(${depth / 2}px) rotateZ(180deg)`,
          ]
        default:
          return [
            // left, front, right, back (rotation around Y-axis)
            `rotateY(-90deg) translateZ(${width / 2}px)`,
            `rotateY(0deg) translateZ(${depth / 2}px)`,
            `rotateY(90deg) translateZ(${width / 2}px)`,
            `rotateY(180deg) translateZ(${depth / 2}px)`,
          ]
      }
    })()

    // Auto play functionality
    useEffect(() => {
      if (autoPlay && items.length > 0) {
        const interval = setInterval(next, autoPlayInterval)
        return () => clearInterval(interval)
      }
    }, [autoPlay, items.length, next, autoPlayInterval])

    const handleKeyDown = useCallback(
      (e: React.KeyboardEvent) => {
        if (isRotating.current) return

        switch (e.key) {
          case "ArrowLeft":
            e.preventDefault()
            if (direction === "left" || direction === "right") {
              prev()
            }
            break
          case "ArrowRight":
            e.preventDefault()
            if (direction === "left" || direction === "right") {
              next()
            }
            break
          case "ArrowUp":
            e.preventDefault()
            if (direction === "top" || direction === "bottom") {
              prev()
            }
            break
          case "ArrowDown":
            e.preventDefault()
            if (direction === "top" || direction === "bottom") {
              next()
            }
            break
          default:
            break
        }
      },
      [direction, next, prev, items.length]
    )

    return (
      <div
        className={cn("relative focus:outline-0", enableDrag && "cursor-move", className)}
        style={{
          width,
          height,
          perspective: `${perspective}px`,
        }}
        onKeyDown={handleKeyDown}
        tabIndex={0}
        aria-label={`3D carousel with ${items.length} items`}
        aria-describedby="carousel-instructions"
        aria-live="polite"
        aria-atomic="true"
        onMouseDown={handleDragStart}
        onTouchStart={handleDragStart}
        {...props}
      >
        <div className="sr-only" aria-live="assertive">
          Showing item {currentItemIndex + 1} of {items.length}:{" "}
          {items[currentItemIndex]?.alt || `Item ${currentItemIndex + 1}`}
        </div>

        <motion.div
          className="relative w-full h-full [transform-style:preserve-3d]"
          style={{
            transform: transform,
          }}
        >
          {/* First face */}
          <CubeFace
            transform={faceTransforms[0]}
            style={
              debug
                ? { width, height, backgroundColor: "#ff9999" }
                : { width, height }
            }
            debug={debug}
          >
            <MediaRenderer item={items[prevIndex]} debug={debug} />
          </CubeFace>

          {/* Second face */}
          <CubeFace
            transform={faceTransforms[1]}
            style={
              debug
                ? { width, height, backgroundColor: "#99ff99" }
                : { width, height }
            }
            debug={debug}
          >
            <MediaRenderer item={items[currentIndex]} debug={debug} />
          </CubeFace>

          {/* Third face */}
          <CubeFace
            transform={faceTransforms[2]}
            style={
              debug
                ? { width, height, backgroundColor: "#9999ff" }
                : { width, height }
            }
            debug={debug}
          >
            <MediaRenderer item={items[nextIndex]} debug={debug} />
          </CubeFace>

          {/* Fourth face */}
          <CubeFace
            transform={faceTransforms[3]}
            style={
              debug
                ? { width, height, backgroundColor: "#ffff99" }
                : { width, height }
            }
            debug={debug}
          >
            <MediaRenderer item={items[afterNextIndex]} debug={debug} />
          </CubeFace>
        </motion.div>
      </div>
    )
  }
)

BoxCarousel.displayName = "BoxCarousel"

export default BoxCarousel
export type { CarouselItem, RotationDirection, SpringConfig }
src/fancy/components/filter/gooey-svg-filter.tsx
파일 저장

const GooeySvgFilter = ({
  id = "gooey-filter",
  strength = 10,
}: {
  id?: string
  strength?: number
}) => {
  return (
    <svg className="hidden absolute">
      <defs>
        <filter id={id}>
          <feGaussianBlur
            in="SourceGraphic"
            stdDeviation={strength}
            result="blur-sm"
          />
          <feColorMatrix
            in="blur-sm"
            type="matrix"
            values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9"
            result="goo"
          />
          <feComposite in="SourceGraphic" in2="goo" operator="atop" />
        </filter>
      </defs>
    </svg>
  )
}

export default GooeySvgFilter
src/fancy/components/filter/pixelate-svg-filter.tsx
파일 저장

interface PixelateSvgFilterProps {
  id: string
  size?: number
  crossLayers?: boolean
}

export default function PixelateSvgFilter({
  id = "pixelate-filter",
  size = 16,
  crossLayers = false,
}: PixelateSvgFilterProps) {
  return (
    <svg className="absolute inset-0">
      <defs>
        <filter id={id} x="0" y="0" width="1" height="1">
          {"First layer: Normal pixelation effect"}
          <feConvolveMatrix
            kernelMatrix="1 1 1
                          1 1 1
                          1 1 1"
            result="AVG"
          />
          <feFlood x="1" y="1" width="1" height="1" />
          <feComposite
            operator="arithmetic"
            k1="0"
            k2="1"
            k3="0"
            k4="0"
            width={size}
            height={size}
          />
          <feTile result="TILE" />
          <feComposite
            in="AVG"
            in2="TILE"
            operator="in"
            k1="0"
            k2="1"
            k3="0"
            k4="0"
          />
          <feMorphology operator="dilate" radius={size / 2} result={"NORMAL"} />
          {crossLayers && (
            <>
              {"Second layer: Fallback with full-width tiling"}
              <feConvolveMatrix
                kernelMatrix="1 1 1
                              1 1 1
                              1 1 1"
                result="AVG"
              />
              <feFlood x="1" y="1" width="1" height="1" />
              <feComposite
                in2="SourceGraphic"
                operator="arithmetic"
                k1="0"
                k2="1"
                k3="0"
                k4="0"
                width={size / 2}
                height={size}
              />
              <feTile result="TILE" />
              <feComposite
                in="AVG"
                in2="TILE"
                operator="in"
                k1="0"
                k2="1"
                k3="0"
                k4="0"
              />
              <feMorphology
                operator="dilate"
                radius={size / 2}
                result={"FALLBACKX"}
              />
              {"Third layer: Fallback with full-height tiling"}
              <feConvolveMatrix
                kernelMatrix="1 1 1
                              1 1 1
                              1 1 1"
                result="AVG"
              />
              <feFlood x="1" y="1" width="1" height="1" />
              <feComposite
                in2="SourceGraphic"
                operator="arithmetic"
                k1="0"
                k2="1"
                k3="0"
                k4="0"
                width={size}
                height={size / 2}
              />
              <feTile result="TILE" />
              <feComposite
                in="AVG"
                in2="TILE"
                operator="in"
                k1="0"
                k2="1"
                k3="0"
                k4="0"
              />
              <feMorphology
                operator="dilate"
                radius={size / 2}
                result={"FALLBACKY"}
              />
              <feMerge>
                <feMergeNode in="FALLBACKX" />
                <feMergeNode in="FALLBACKY" />
                <feMergeNode in="NORMAL" />
              </feMerge>
            </>
          )}
          {!crossLayers && <feMergeNode in="NORMAL" />}
        </filter>
      </defs>
    </svg>
  )
}
src/fancy/components/image/image-trail.tsx
파일 저장

// author: Khoa Phan <https://www.pldkhoa.dev>

"use client"

import React, { ElementType, HTMLAttributes, useEffect, useMemo } from "react"
import type { DOMKeyframesDefinition, AnimationOptions } from "motion"
import { useAnimate } from "motion/react"

import { cn } from "@/lib/utils"

interface ImageTrailProps extends HTMLAttributes<HTMLDivElement> {
  /**
   * The content to be displayed
   */
  children: React.ReactNode

  /**
   * HTML Tag
   */
  as?: ElementType

  /**
   * How much distance in pixels the mouse has to travel to trigger of an element to appear.
   */
  threshold?: number

  /**
   * The intensity for the momentum movement after showing the element. The value will be clamped > 0 and <= 1.0. Defaults to 0.3.
   */
  intensity?: number

  /**
   * Animation Keyframes for defining the animation sequence. Example: { scale: [0, 1, 1, 0] }
   */
  keyframes?: DOMKeyframesDefinition

  /**
   * Options for the animation/keyframes. Example: { duration: 1, times: [0, 0.1, 0.9, 1] }
   */
  keyframesOptions?: AnimationOptions

  /**
   * Animation keyframes for the x and y positions after showing the element. Describes how the element should try to arrive at the mouse position.
   */
  trailElementAnimationKeyframes?: {
    x?: AnimationOptions
    y?: AnimationOptions
  }

  /**
   * The number of times the children will be repeated. Defaults to 3.
   */
  repeatChildren?: number

  /**
   * The base zIndex for all elements. Defaults to 0.
   */
  baseZIndex?: number

  /**
   * Controls stacking order behavior.
   * - "new-on-top": newer elements stack above older ones (default)
   * - "old-on-top": older elements stay visually on top
   */
  zIndexDirection?: "new-on-top" | "old-on-top"
}

interface ImageTrailItemProps extends HTMLAttributes<HTMLDivElement> {
  /**
   * HTML Tag
   */
  as?: ElementType

  /**
   * The content to be displayed
   */
  children: React.ReactNode
}

/**
 * Helper functions
 */
const MathUtils = {
  // linear interpolation
  lerp: (a: number, b: number, n: number) => (1 - n) * a + n * b,
  // distance between two points
  distance: (x1: number, y1: number, x2: number, y2: number) =>
    Math.hypot(x2 - x1, y2 - y1),
}

const ImageTrail = ({
  className,
  as = "div",
  children,
  threshold = 100,
  intensity = 0.3,
  keyframes,
  keyframesOptions,
  repeatChildren = 3,
  trailElementAnimationKeyframes = {
    x: { duration: 1, type: "tween", ease: "easeOut" },
    y: { duration: 1, type: "tween", ease: "easeOut" },
  },
  baseZIndex = 0,
  zIndexDirection = "new-on-top",
  ...props
}: ImageTrailProps) => {
  const allImages = React.useRef<NodeListOf<HTMLElement>>(undefined)
  const currentId = React.useRef(0)
  const lastMousePos = React.useRef({ x: 0, y: 0 })
  const cachedMousePos = React.useRef({ x: 0, y: 0 })
  const [containerRef, animate] = useAnimate()
  const zIndices = React.useRef<number[]>([])

  const clampedIntensity = useMemo(
    () => Math.max(0.0001, Math.min(1, intensity)),
    [intensity]
  )

  useEffect(() => {
    allImages.current = containerRef?.current?.querySelectorAll(
      ".image-trail-item"
    ) as NodeListOf<HTMLElement>

    zIndices.current = Array.from(
      { length: allImages.current.length },
      (_, index) => index
    )
  }, [containerRef, allImages])

  const handleMouseMove = (e: React.MouseEvent) => {
    const containerRect = containerRef?.current?.getBoundingClientRect()
    const mousePos = {
      x: e.clientX - (containerRect?.left || 0),
      y: e.clientY - (containerRect?.top || 0),
    }

    cachedMousePos.current.x = MathUtils.lerp(
      cachedMousePos.current.x || mousePos.x,
      mousePos.x,
      clampedIntensity
    )

    cachedMousePos.current.y = MathUtils.lerp(
      cachedMousePos.current.y || mousePos.y,
      mousePos.y,
      clampedIntensity
    )

    const distance = MathUtils.distance(
      mousePos.x,
      mousePos.y,
      lastMousePos.current.x,
      lastMousePos.current.y
    )

    if (distance > threshold && allImages?.current) {
      const N = allImages.current.length
      const current = currentId.current

      if (zIndexDirection === "new-on-top") {
        // Shift others down, put current on top
        for (let i = 0; i < N; i++) {
          if (i !== current) {
            zIndices.current[i] -= 1
          }
        }
        zIndices.current[current] = N - 1
      } else {
        // Shift others up, put current at bottom
        for (let i = 0; i < N; i++) {
          if (i !== current) {
            zIndices.current[i] += 1
          }
        }
        zIndices.current[current] = 0
      }

      allImages.current[current].style.display = "block"
      allImages.current.forEach((img, index) => {
        img.style.zIndex = String(zIndices.current[index] + baseZIndex)
      })

      animate(
        allImages.current[currentId.current],
        {
          x: [
            cachedMousePos.current.x -
              allImages.current[currentId.current].offsetWidth / 2,
            mousePos.x - allImages.current[currentId.current].offsetWidth / 2,
          ],
          y: [
            cachedMousePos.current.y -
              allImages.current[currentId.current].offsetHeight / 2,
            mousePos.y -
              allImages.current?.[currentId.current].offsetHeight / 2,
          ],
          ...keyframes,
        },
        {
          ...trailElementAnimationKeyframes.x,
          ...trailElementAnimationKeyframes.y,
          ...keyframesOptions,
        }
      )
      currentId.current = (current + 1) % N
      lastMousePos.current = { x: mousePos.x, y: mousePos.y }
    }
  }

  const ElementTag = as ?? "div"

  return (
    <ElementTag
      className={cn("h-full w-full relative", className)}
      onMouseMove={handleMouseMove}
      ref={containerRef}
      {...props}
    >
      {Array.from({ length: repeatChildren }).map(() => (
        <>{children}</>
      ))}
    </ElementTag>
  )
}

export const ImageTrailItem = ({
  className,
  children,
  as = "div",
  ...props
}: ImageTrailItemProps) => {
  const ElementTag = as ?? "div"
  return (
    <ElementTag
      {...props}
      className={cn(
        "absolute top-0 left-0 will-change-transform hidden",
        className,
        "image-trail-item"
      )}
    >
      {children}
    </ElementTag>
  )
}

export default ImageTrail
src/fancy/components/image/parallax-floating.tsx
파일 저장

"use client"

import {
  createContext,
  ReactNode,
  useCallback,
  useContext,
  useEffect,
  useRef,
} from "react"
import { useAnimationFrame } from "motion/react"

import { cn } from "@/lib/utils"
import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"

interface FloatingContextType {
  registerElement: (id: string, element: HTMLDivElement, depth: number) => void
  unregisterElement: (id: string) => void
}

const FloatingContext = createContext<FloatingContextType | null>(null)

interface FloatingProps {
  children: ReactNode
  className?: string
  sensitivity?: number
  easingFactor?: number
}

const Floating = ({
  children,
  className,
  sensitivity = 1,
  easingFactor = 0.05,
  ...props
}: FloatingProps) => {
  const containerRef = useRef<HTMLDivElement>(null)
  const elementsMap = useRef(
    new Map<
      string,
      {
        element: HTMLDivElement
        depth: number
        currentPosition: { x: number; y: number }
      }
    >()
  )
  const mousePositionRef = useMousePositionRef(containerRef)

  const registerElement = useCallback(
    (id: string, element: HTMLDivElement, depth: number) => {
      elementsMap.current.set(id, {
        element,
        depth,
        currentPosition: { x: 0, y: 0 },
      })
    },
    []
  )

  const unregisterElement = useCallback((id: string) => {
    elementsMap.current.delete(id)
  }, [])

  useAnimationFrame(() => {
    if (!containerRef.current) return

    elementsMap.current.forEach((data) => {
      const strength = (data.depth * sensitivity) / 20

      // Calculate new target position
      const newTargetX = mousePositionRef.current.x * strength
      const newTargetY = mousePositionRef.current.y * strength

      // Check if we need to update
      const dx = newTargetX - data.currentPosition.x
      const dy = newTargetY - data.currentPosition.y

      // Update position only if we're still moving
      data.currentPosition.x += dx * easingFactor
      data.currentPosition.y += dy * easingFactor

      data.element.style.transform = `translate3d(${data.currentPosition.x}px, ${data.currentPosition.y}px, 0)`
    })
  })

  return (
    <FloatingContext.Provider value={{ registerElement, unregisterElement }}>
      <div
        ref={containerRef}
        className={cn("absolute top-0 left-0 w-full h-full", className)}
        {...props}
      >
        {children}
      </div>
    </FloatingContext.Provider>
  )
}

export default Floating

interface FloatingElementProps {
  children: ReactNode
  className?: string
  depth?: number
}

export const FloatingElement = ({
  children,
  className,
  depth = 1,
}: FloatingElementProps) => {
  const elementRef = useRef<HTMLDivElement>(null)
  const idRef = useRef(Math.random().toString(36).substring(7))
  const context = useContext(FloatingContext)

  useEffect(() => {
    if (!elementRef.current || !context) return

    const nonNullDepth = depth ?? 0.01

    context.registerElement(idRef.current, elementRef.current, nonNullDepth)
    return () => context.unregisterElement(idRef.current)
  }, [depth])

  return (
    <div
      ref={elementRef}
      className={cn("absolute will-change-transform", className)}
    >
      {children}
    </div>
  )
}
src/fancy/components/physics/cursor-attractor-and-gravity.tsx
파일 저장

"use client"

import {
  createContext,
  forwardRef,
  ReactNode,
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from "react"
import { calculatePosition } from "@/utils/calculate-position"
import { parsePathToVertices } from "@/utils/svg-path-to-vertices"
import { debounce } from "lodash"
import Matter, {
  Bodies,
  Common,
  Engine,
  Events,
  Render,
  Runner,
  World,
  Body,
} from "matter-js"

import { cn } from "@/lib/utils"
import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"

type GravityProps = {
  children: ReactNode
  debug?: boolean
  attractorPoint?: { x: number | string; y: number | string }
  attractorStrength?: number
  cursorStrength?: number
  cursorFieldRadius?: number
  resetOnResize?: boolean
  addTopWall?: boolean
  autoStart?: boolean
  className?: string
}

type PhysicsBody = {
  element: HTMLElement
  body: Matter.Body
  props: MatterBodyProps
}

type MatterBodyProps = {
  children: ReactNode
  matterBodyOptions?: Matter.IBodyDefinition
  isDraggable?: boolean
  bodyType?: "rectangle" | "circle" | "svg"
  sampleLength?: number
  x?: number | string
  y?: number | string
  angle?: number
  className?: string
}

export type GravityRef = {
  start: () => void
  stop: () => void
  reset: () => void
}

const GravityContext = createContext<{
  registerElement: (
    id: string,
    element: HTMLElement,
    props: MatterBodyProps
  ) => void
  unregisterElement: (id: string) => void
} | null>(null)

export const MatterBody = ({
  children,
  className,
  matterBodyOptions = {
    friction: 0.1,
    restitution: 0.1,
    density: 0.001,
    isStatic: false,
  },
  bodyType = "rectangle",
  isDraggable = true,
  sampleLength = 15,
  x = 0,
  y = 0,
  angle = 0,
  ...props
}: MatterBodyProps) => {
  const elementRef = useRef<HTMLDivElement>(null)
  const idRef = useRef(Math.random().toString(36).substring(7))
  const context = useContext(GravityContext)

  useEffect(() => {
    if (!elementRef.current || !context) return
    context.registerElement(idRef.current, elementRef.current, {
      children,
      matterBodyOptions,
      bodyType,
      sampleLength,
      isDraggable,
      x,
      y,
      angle,
      ...props,
    })

    return () => context.unregisterElement(idRef.current)
  }, [props, children, matterBodyOptions, isDraggable])

  return (
    <div
      ref={elementRef}
      className={cn(
        "absolute",
        className,
      )}
    >
      {children}
    </div>
  )
}

const Gravity = forwardRef<GravityRef, GravityProps>(
  (
    {
      children,
      debug = false,
      attractorPoint = { x: 0.5, y: 0.5 },
      attractorStrength = 0.001,
      cursorStrength = 0.0005,
      cursorFieldRadius = 100,
      resetOnResize = true,
      addTopWall = true,
      autoStart = true,
      className,
      ...props
    },
    ref
  ) => {
    const canvas = useRef<HTMLDivElement>(null)
    const engine = useRef(Engine.create())
    const render = useRef<Render>(undefined)
    const runner = useRef<Runner>(undefined)
    const bodiesMap = useRef(new Map<string, PhysicsBody>())
    const frameId = useRef<number>(undefined)
    const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
    const mouseRef = useMousePositionRef(canvas)

    const isRunning = useRef(false)

    // Register Matter.js body in the physics world
    const registerElement = useCallback(
      (id: string, element: HTMLElement, props: MatterBodyProps) => {
        if (!canvas.current) return
        const width = element.offsetWidth
        const height = element.offsetHeight
        const canvasRect = canvas.current!.getBoundingClientRect()

        const angle = (props.angle || 0) * (Math.PI / 180)

        const x = calculatePosition(props.x, canvasRect.width, width)
        const y = calculatePosition(props.y, canvasRect.height, height)

        let body
        if (props.bodyType === "circle") {
          const radius = Math.max(width, height) / 2
          body = Bodies.circle(x, y, radius, {
            ...props.matterBodyOptions,
            angle: angle,
            render: {
              fillStyle: debug ? "#888888" : "#00000000",
              strokeStyle: debug ? "#333333" : "#00000000",
              lineWidth: debug ? 3 : 0,
            },
          })
        } else if (props.bodyType === "svg") {
          const paths = element.querySelectorAll("path")
          const vertexSets: Matter.Vector[][] = []

          paths.forEach((path) => {
            const d = path.getAttribute("d")
            const p = parsePathToVertices(d!, props.sampleLength)
            vertexSets.push(p)
          })

          body = Bodies.fromVertices(x, y, vertexSets, {
            ...props.matterBodyOptions,
            angle: angle,
            render: {
              fillStyle: debug ? "#888888" : "#00000000",
              strokeStyle: debug ? "#333333" : "#00000000",
              lineWidth: debug ? 3 : 0,
            },
          })
        } else {
          body = Bodies.rectangle(x, y, width, height, {
            ...props.matterBodyOptions,
            angle: angle,
            render: {
              fillStyle: debug ? "#888888" : "#00000000",
              strokeStyle: debug ? "#333333" : "#00000000",
              lineWidth: debug ? 3 : 0,
            },
          })
        }

        if (body) {
          World.add(engine.current.world, [body])
          bodiesMap.current.set(id, { element, body, props })
        }
      },
      [debug]
    )

    // Unregister Matter.js body from the physics world
    const unregisterElement = useCallback((id: string) => {
      const body = bodiesMap.current.get(id)
      if (body) {
        World.remove(engine.current.world, body.body)
        bodiesMap.current.delete(id)
      }
    }, [])

    // Keep react elements in sync with the physics world
    const updateElements = useCallback(() => {
      bodiesMap.current.forEach(({ element, body }) => {
        const { x, y } = body.position
        const rotation = body.angle * (180 / Math.PI)

        element.style.transform = `translate(${
          x - element.offsetWidth / 2
        }px, ${y - element.offsetHeight / 2}px) rotate(${rotation}deg)`
      })

      frameId.current = requestAnimationFrame(updateElements)
    }, [])

    const initializeRenderer = useCallback(() => {
      if (!canvas.current) return

      const height = canvas.current.offsetHeight
      const width = canvas.current.offsetWidth

      Common.setDecomp(require("poly-decomp"))

      // Remove default gravity
      engine.current.gravity.x = 0
      engine.current.gravity.y = 0

      render.current = Render.create({
        element: canvas.current,
        engine: engine.current,
        options: {
          width,
          height,
          wireframes: false,
          background: "#00000000",
        },
      })

      // Add walls
      const walls = [
        // Floor
        Bodies.rectangle(width / 2, height + 10, width, 20, {
          isStatic: true,
          friction: 1,
          render: {
            visible: debug,
          },
        }),

        // Right wall
        Bodies.rectangle(width + 10, height / 2, 20, height, {
          isStatic: true,
          friction: 1,
          render: {
            visible: debug,
          },
        }),

        // Left wall
        Bodies.rectangle(-10, height / 2, 20, height, {
          isStatic: true,
          friction: 1,
          render: {
            visible: debug,
          },
        }),
      ]

      const topWall = addTopWall
        ? Bodies.rectangle(width / 2, -10, width, 20, {
            isStatic: true,
            friction: 1,
            render: {
              visible: debug,
            },
          })
        : null

      if (topWall) {
        walls.push(topWall)
      }

      World.add(engine.current.world, [...walls])

      runner.current = Runner.create()
      Render.run(render.current)
      updateElements()
      runner.current.enabled = false

      if (autoStart) {
        runner.current.enabled = true
        startEngine()
      }

      // Add force application before update
      Events.on(engine.current, "beforeUpdate", () => {
        const bodies = engine.current.world.bodies.filter(
          (body) => !body.isStatic
        )

        // Calculate attractor position in pixels
        const attractorX = typeof attractorPoint.x === 'string' 
          ? (width * parseFloat(attractorPoint.x) / 100)
          : width * attractorPoint.x
        const attractorY = typeof attractorPoint.y === 'string'
          ? (height * parseFloat(attractorPoint.y) / 100) 
          : height * attractorPoint.y

        bodies.forEach((body) => {
          // Apply attractor force
          const dx = attractorX - body.position.x
          const dy = attractorY - body.position.y
          const distance = Math.sqrt(dx * dx + dy * dy)

          if (distance > 0) {
            const force = {
              x: (dx / distance) * attractorStrength * body.mass,
              y: (dy / distance) * attractorStrength * body.mass,
            }
            Body.applyForce(body, body.position, force)
          }

          // Apply cursor force if mouse is present
          if (mouseRef.current?.x && mouseRef.current?.y && mouseRef.current.x > 0 && mouseRef.current.y > 0) {
            const mdx = mouseRef.current.x - body.position.x
            const mdy = mouseRef.current.y - body.position.y
            const mouseDistance = Math.sqrt(mdx * mdx + mdy * mdy)

            if (mouseDistance > 0 && mouseDistance < cursorFieldRadius) {
              const mouseForce = {
                x: (mdx / mouseDistance) * cursorStrength * body.mass,
                y: (mdy / mouseDistance) * cursorStrength * body.mass,
              }
              Body.applyForce(body, body.position, mouseForce)
            }
          }
        })
      })
    }, [updateElements, debug, autoStart, attractorPoint, attractorStrength, cursorStrength])

    // Clear the Matter.js world
    const clearRenderer = useCallback(() => {
      if (frameId.current) {
        cancelAnimationFrame(frameId.current)
      }

      if (render.current) {
        Render.stop(render.current)
        render.current.canvas.remove()
      }

      if (runner.current) {
        Runner.stop(runner.current)
      }

      if (engine.current) {
        World.clear(engine.current.world, false)
        Engine.clear(engine.current)
      }

      bodiesMap.current.clear()
    }, [])

    const handleResize = useCallback(() => {
      if (!canvas.current || !resetOnResize) return

      const newWidth = canvas.current.offsetWidth
      const newHeight = canvas.current.offsetHeight

      setCanvasSize({ width: newWidth, height: newHeight })

      // Clear and reinitialize
      clearRenderer()
      initializeRenderer()
    }, [clearRenderer, initializeRenderer, resetOnResize])

    const startEngine = useCallback(() => {
      if (runner.current) {
        runner.current.enabled = true

        Runner.run(runner.current, engine.current)
      }
      if (render.current) {
        Render.run(render.current)
      }
      frameId.current = requestAnimationFrame(updateElements)
      isRunning.current = true
    }, [updateElements, canvasSize])

    const stopEngine = useCallback(() => {
      if (!isRunning.current) return

      if (runner.current) {
        Runner.stop(runner.current)
      }
      if (render.current) {
        Render.stop(render.current)
      }
      if (frameId.current) {
        cancelAnimationFrame(frameId.current)
      }
      isRunning.current = false
    }, [])

    const reset = useCallback(() => {
      stopEngine()
      bodiesMap.current.forEach(({ element, body, props }) => {
        body.angle = props.angle || 0

        const x = calculatePosition(
          props.x,
          canvasSize.width,
          element.offsetWidth
        )
        const y = calculatePosition(
          props.y,
          canvasSize.height,
          element.offsetHeight
        )
        body.position.x = x
        body.position.y = y
      })
      updateElements()
      handleResize()
    }, [])

    useImperativeHandle(
      ref,
      () => ({
        start: startEngine,
        stop: stopEngine,
        reset,
      }),
      [startEngine, stopEngine]
    )

    useEffect(() => {
      if (!resetOnResize) return

      const debouncedResize = debounce(handleResize, 500)
      window.addEventListener("resize", debouncedResize)

      return () => {
        window.removeEventListener("resize", debouncedResize)
        debouncedResize.cancel()
      }
    }, [handleResize, resetOnResize])

    useEffect(() => {
      initializeRenderer()
      return clearRenderer
    }, [initializeRenderer, clearRenderer])

    return (
      <GravityContext.Provider value={{ registerElement, unregisterElement }}>
        <div
          ref={canvas}
          className={cn(className, "absolute top-0 left-0 w-full h-full")}
          {...props}
        >
          {children}
        </div>
      </GravityContext.Provider>
    )
  }
)

Gravity.displayName = "Gravity"
export default Gravity
src/fancy/components/physics/elastic-line.tsx
파일 저장

"use client"

import React, { useEffect, useRef, useState } from "react"
import {
  animate,
  motion,
  useAnimationFrame,
  useMotionValue,
  ValueAnimationTransition,
} from "motion/react"

import { useDimensions } from "@/hooks/use-dimensions"
import { useElasticLineEvents } from "@/hooks/use-elastic-line-events"

interface ElasticLineProps {
  isVertical?: boolean
  grabThreshold?: number
  releaseThreshold?: number
  strokeWidth?: number
  transition?: ValueAnimationTransition
  animateInTransition?: ValueAnimationTransition
  className?: string
}

const ElasticLine: React.FC<ElasticLineProps> = ({
  isVertical = false,
  grabThreshold = 5,
  releaseThreshold = 100,
  strokeWidth = 1,
  transition = {
    type: "spring",
    stiffness: 300,
    damping: 5,
  },
  animateInTransition = {
    duration: 0.3,
    ease: "easeInOut",
  },
  className,
}) => {
  const containerRef = useRef<SVGSVGElement>(null)
  const dimensions = useDimensions(containerRef)
  const pathRef = useRef<SVGPathElement>(null)
  const [hasAnimatedIn, setHasAnimatedIn] = useState(false)

  // Clamp releaseThreshold to container dimensions
  const clampedReleaseThreshold = Math.min(
    releaseThreshold,
    isVertical ? dimensions.width / 2 : dimensions.height / 2
  )

  const { isGrabbed, controlPoint } = useElasticLineEvents(
    containerRef,
    isVertical,
    grabThreshold,
    clampedReleaseThreshold
  )

  const x = useMotionValue(dimensions.width / 2)
  const y = useMotionValue(dimensions.height / 2)
  const pathLength = useMotionValue(0)

  useEffect(() => {
    // Initial draw animation
    if (!hasAnimatedIn && dimensions.width > 0 && dimensions.height > 0) {
      animate(pathLength, 1, {
        ...animateInTransition,
        onComplete: () => setHasAnimatedIn(true),
      })
    }
    x.set(dimensions.width / 2)
    y.set(dimensions.height / 2)
  }, [dimensions, hasAnimatedIn])

  useEffect(() => {
    if (!isGrabbed && hasAnimatedIn) {
      animate(x, dimensions.width / 2, transition)
      animate(y, dimensions.height / 2, transition)
    }
  }, [isGrabbed])

  useAnimationFrame(() => {
    if (isGrabbed) {
      x.set(controlPoint.x)
      y.set(controlPoint.y)
    }

    const controlX = hasAnimatedIn ? x.get() : dimensions.width / 2
    const controlY = hasAnimatedIn ? y.get() : dimensions.height / 2

    pathRef.current?.setAttribute(
      "d",
      isVertical
        ? `M${dimensions.width / 2} 0Q${controlX} ${controlY} ${
            dimensions.width / 2
          } ${dimensions.height}`
        : `M0 ${dimensions.height / 2}Q${controlX} ${controlY} ${
            dimensions.width
          } ${dimensions.height / 2}`
    )
  })

  return (
    <svg
      ref={containerRef}
      className={`w-full h-full ${className}`}
      viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
      preserveAspectRatio="none"
    >
      <motion.path
        ref={pathRef}
        stroke="currentColor"
        strokeWidth={strokeWidth}
        initial={{ pathLength: 0 }}
        style={{ pathLength }}
        fill="none"
      />
    </svg>
  )
}

export default ElasticLine
src/fancy/components/physics/gravity.tsx
파일 저장

"use client"

import {
  createContext,
  forwardRef,
  ReactNode,
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from "react"
import { calculatePosition } from "@/utils/calculate-position"
import { parsePathToVertices } from "@/utils/svg-path-to-vertices"
import { debounce } from "lodash"
import Matter, {
  Bodies,
  Common,
  Engine,
  Events,
  Mouse,
  MouseConstraint,
  Query,
  Render,
  Runner,
  World,
} from "matter-js"

import { cn } from "@/lib/utils"

type GravityProps = {
  children: ReactNode
  debug?: boolean
  gravity?: { x: number; y: number }
  resetOnResize?: boolean
  grabCursor?: boolean
  addTopWall?: boolean
  autoStart?: boolean
  className?: string
}

type PhysicsBody = {
  element: HTMLElement
  body: Matter.Body
  props: MatterBodyProps
}

type MatterBodyProps = {
  children: ReactNode
  matterBodyOptions?: Matter.IBodyDefinition
  isDraggable?: boolean
  bodyType?: "rectangle" | "circle" | "svg"
  sampleLength?: number
  x?: number | string
  y?: number | string
  angle?: number
  className?: string
}

export type GravityRef = {
  start: () => void
  stop: () => void
  reset: () => void
}

const GravityContext = createContext<{
  registerElement: (
    id: string,
    element: HTMLElement,
    props: MatterBodyProps
  ) => void
  unregisterElement: (id: string) => void
} | null>(null)

export const MatterBody = ({
  children,
  className,
  matterBodyOptions = {
    friction: 0.1,
    restitution: 0.1,
    density: 0.001,
    isStatic: false,
  },
  bodyType = "rectangle",
  isDraggable = true,
  sampleLength = 15,
  x = 0,
  y = 0,
  angle = 0,
  ...props
}: MatterBodyProps) => {
  const elementRef = useRef<HTMLDivElement>(null)
  const idRef = useRef(Math.random().toString(36).substring(7))
  const context = useContext(GravityContext)

  useEffect(() => {
    if (!elementRef.current || !context) return
    context.registerElement(idRef.current, elementRef.current, {
      children,
      matterBodyOptions,
      bodyType,
      sampleLength,
      isDraggable,
      x,
      y,
      angle,
      ...props,
    })

    return () => context.unregisterElement(idRef.current)
  }, [props, children, matterBodyOptions, isDraggable])

  return (
    <div
      ref={elementRef}
      className={cn(
        "absolute",
        className,
        isDraggable && "pointer-events-none"
      )}
    >
      {children}
    </div>
  )
}

const Gravity = forwardRef<GravityRef, GravityProps>(
  (
    {
      children,
      debug = false,
      gravity = { x: 0, y: 1 },
      grabCursor = true,
      resetOnResize = true,
      addTopWall = true,
      autoStart = true,
      className,
      ...props
    },
    ref
  ) => {
    const canvas = useRef<HTMLDivElement>(null)
    const engine = useRef(Engine.create())
    const render = useRef<Render>(undefined)
    const runner = useRef<Runner>(undefined)
    const bodiesMap = useRef(new Map<string, PhysicsBody>())
    const frameId = useRef<number>(undefined)
    const mouseConstraint = useRef<Matter.MouseConstraint>(undefined)
    const mouseDown = useRef(false)
    const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })

    const isRunning = useRef(false)

    // Register Matter.js body in the physics world
    const registerElement = useCallback(
      (id: string, element: HTMLElement, props: MatterBodyProps) => {
        if (!canvas.current) return
        const width = element.offsetWidth
        const height = element.offsetHeight
        const canvasRect = canvas.current!.getBoundingClientRect()

        const angle = (props.angle || 0) * (Math.PI / 180)

        const x = calculatePosition(props.x, canvasRect.width, width)
        const y = calculatePosition(props.y, canvasRect.height, height)

        let body
        if (props.bodyType === "circle") {
          const radius = Math.max(width, height) / 2
          body = Bodies.circle(x, y, radius, {
            ...props.matterBodyOptions,
            angle: angle,
            render: {
              fillStyle: debug ? "#888888" : "#00000000",
              strokeStyle: debug ? "#333333" : "#00000000",
              lineWidth: debug ? 3 : 0,
            },
          })
        } else if (props.bodyType === "svg") {
          const paths = element.querySelectorAll("path")
          const vertexSets: Matter.Vector[][] = []

          paths.forEach((path) => {
            const d = path.getAttribute("d")
            const p = parsePathToVertices(d!, props.sampleLength)
            vertexSets.push(p)
          })

          body = Bodies.fromVertices(x, y, vertexSets, {
            ...props.matterBodyOptions,
            angle: angle,
            render: {
              fillStyle: debug ? "#888888" : "#00000000",
              strokeStyle: debug ? "#333333" : "#00000000",
              lineWidth: debug ? 3 : 0,
            },
          })
        } else {
          body = Bodies.rectangle(x, y, width, height, {
            ...props.matterBodyOptions,
            angle: angle,
            render: {
              fillStyle: debug ? "#888888" : "#00000000",
              strokeStyle: debug ? "#333333" : "#00000000",
              lineWidth: debug ? 3 : 0,
            },
          })
        }

        if (body) {
          World.add(engine.current.world, [body])
          bodiesMap.current.set(id, { element, body, props })
        }
      },
      [debug]
    )

    // Unregister Matter.js body from the physics world
    const unregisterElement = useCallback((id: string) => {
      const body = bodiesMap.current.get(id)
      if (body) {
        World.remove(engine.current.world, body.body)
        bodiesMap.current.delete(id)
      }
    }, [])

    // Keep react elements in sync with the physics world
    const updateElements = useCallback(() => {
      bodiesMap.current.forEach(({ element, body }) => {
        const { x, y } = body.position
        const rotation = body.angle * (180 / Math.PI)

        element.style.transform = `translate(${
          x - element.offsetWidth / 2
        }px, ${y - element.offsetHeight / 2}px) rotate(${rotation}deg)`
      })

      frameId.current = requestAnimationFrame(updateElements)
    }, [])

    const initializeRenderer = useCallback(() => {
      if (!canvas.current) return

      const height = canvas.current.offsetHeight
      const width = canvas.current.offsetWidth

      Common.setDecomp(require("poly-decomp"))

      engine.current.gravity.x = gravity.x
      engine.current.gravity.y = gravity.y

      render.current = Render.create({
        element: canvas.current,
        engine: engine.current,
        options: {
          width,
          height,
          wireframes: false,
          background: "#00000000",
        },
      })

      const mouse = Mouse.create(render.current.canvas)
      mouseConstraint.current = MouseConstraint.create(engine.current, {
        mouse: mouse,
        constraint: {
          stiffness: 0.2,
          render: {
            visible: debug,
          },
        },
      })

      // Add walls
      const walls = [
        // Floor
        Bodies.rectangle(width / 2, height + 10, width, 20, {
          isStatic: true,
          friction: 1,
          render: {
            visible: debug,
          },
        }),

        // Right wall
        Bodies.rectangle(width + 10, height / 2, 20, height, {
          isStatic: true,
          friction: 1,
          render: {
            visible: debug,
          },
        }),

        // Left wall
        Bodies.rectangle(-10, height / 2, 20, height, {
          isStatic: true,
          friction: 1,
          render: {
            visible: debug,
          },
        }),
      ]

      const topWall = addTopWall
        ? Bodies.rectangle(width / 2, -10, width, 20, {
            isStatic: true,
            friction: 1,
            render: {
              visible: debug,
            },
          })
        : null

      if (topWall) {
        walls.push(topWall)
      }

      const touchingMouse = () =>
        Query.point(
          engine.current.world.bodies,
          mouseConstraint.current?.mouse.position || { x: 0, y: 0 }
        ).length > 0

      if (grabCursor) {
        Events.on(engine.current, "beforeUpdate", (event) => {
          if (canvas.current) {
            if (!mouseDown.current && !touchingMouse()) {
              canvas.current.style.cursor = "default"
            } else if (touchingMouse()) {
              canvas.current.style.cursor = mouseDown.current
                ? "grabbing"
                : "grab"
            }
          }
        })

        canvas.current.addEventListener("mousedown", (event) => {
          mouseDown.current = true

          if (canvas.current) {
            if (touchingMouse()) {
              canvas.current.style.cursor = "grabbing"
            } else {
              canvas.current.style.cursor = "default"
            }
          }
        })
        canvas.current.addEventListener("mouseup", (event) => {
          mouseDown.current = false

          if (canvas.current) {
            if (touchingMouse()) {
              canvas.current.style.cursor = "grab"
            } else {
              canvas.current.style.cursor = "default"
            }
          }
        })
      }

      World.add(engine.current.world, [mouseConstraint.current, ...walls])

      render.current.mouse = mouse

      runner.current = Runner.create()
      Render.run(render.current)
      updateElements()
      runner.current.enabled = false

      if (autoStart) {
        runner.current.enabled = true
        startEngine()
      }
    }, [updateElements, debug, autoStart])

    // Clear the Matter.js world
    const clearRenderer = useCallback(() => {
      if (frameId.current) {
        cancelAnimationFrame(frameId.current)
      }

      if (mouseConstraint.current) {
        World.remove(engine.current.world, mouseConstraint.current)
      }

      if (render.current) {
        Mouse.clearSourceEvents(render.current.mouse)
        Render.stop(render.current)
        render.current.canvas.remove()
      }

      if (runner.current) {
        Runner.stop(runner.current)
      }

      if (engine.current) {
        World.clear(engine.current.world, false)
        Engine.clear(engine.current)
      }

      bodiesMap.current.clear()
    }, [])

    const handleResize = useCallback(() => {
      if (!canvas.current || !resetOnResize) return

      const newWidth = canvas.current.offsetWidth
      const newHeight = canvas.current.offsetHeight

      setCanvasSize({ width: newWidth, height: newHeight })

      // Clear and reinitialize
      clearRenderer()
      initializeRenderer()
    }, [clearRenderer, initializeRenderer, resetOnResize])

    const startEngine = useCallback(() => {
      if (runner.current) {
        runner.current.enabled = true

        Runner.run(runner.current, engine.current)
      }
      if (render.current) {
        Render.run(render.current)
      }
      frameId.current = requestAnimationFrame(updateElements)
      isRunning.current = true
    }, [updateElements, canvasSize])

    const stopEngine = useCallback(() => {
      if (!isRunning.current) return

      if (runner.current) {
        Runner.stop(runner.current)
      }
      if (render.current) {
        Render.stop(render.current)
      }
      if (frameId.current) {
        cancelAnimationFrame(frameId.current)
      }
      isRunning.current = false
    }, [])

    const reset = useCallback(() => {
      stopEngine()
      bodiesMap.current.forEach(({ element, body, props }) => {
        body.angle = props.angle || 0

        const x = calculatePosition(
          props.x,
          canvasSize.width,
          element.offsetWidth
        )
        const y = calculatePosition(
          props.y,
          canvasSize.height,
          element.offsetHeight
        )
        body.position.x = x
        body.position.y = y
      })
      updateElements()
      handleResize()
    }, [])

    useImperativeHandle(
      ref,
      () => ({
        start: startEngine,
        stop: stopEngine,
        reset,
      }),
      [startEngine, stopEngine]
    )

    useEffect(() => {
      if (!resetOnResize) return

      const debouncedResize = debounce(handleResize, 500)
      window.addEventListener("resize", debouncedResize)

      return () => {
        window.removeEventListener("resize", debouncedResize)
        debouncedResize.cancel()
      }
    }, [handleResize, resetOnResize])

    useEffect(() => {
      initializeRenderer()
      return clearRenderer
    }, [initializeRenderer, clearRenderer])

    return (
      <GravityContext.Provider value={{ registerElement, unregisterElement }}>
        <div
          ref={canvas}
          className={cn(className, "absolute top-0 left-0 w-full h-full")}
          {...props}
        >
          {children}
        </div>
      </GravityContext.Provider>
    )
  }
)

Gravity.displayName = "Gravity"
export default Gravity
src/fancy/components/text/basic-number-ticker.tsx
파일 저장

"use client"

import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useState,
} from "react"
import {
  animate,
  AnimationPlaybackControls,
  motion,
  useMotionValue,
  useTransform,
  ValueAnimationTransition,
} from "motion/react"

import { cn } from "@/lib/utils"

interface NumberTickerProps {
  from: number // Starting value of the animation
  target: number // End value of the animation
  transition?: ValueAnimationTransition // Animation configuration, refer to motion docs for more details
  className?: string // additionl CSS classes for styling
  onStart?: () => void // Callback function when animation starts
  onComplete?: () => void // Callback function when animation completes
  autoStart?: boolean // Whether to start the animation automatically
}

// Ref interface to allow external control of the animation
export interface NumberTickerRef {
  startAnimation: () => void
}

const NumberTicker = forwardRef<NumberTickerRef, NumberTickerProps>(
  (
    {
      from = 0,
      target = 100,
      transition = {
        duration: 3,
        type: "tween",
        ease: "easeInOut",
      },
      className,
      onStart,
      onComplete,
      autoStart = true,
      ...props
    },
    ref
  ) => {
    const count = useMotionValue(from)
    const rounded = useTransform(count, (latest) => Math.round(latest))
    const [controls, setControls] = useState<AnimationPlaybackControls | null>(
      null
    )

    // Function to start the animation
    const startAnimation = useCallback(() => {
      if (controls) controls.stop()
      onStart?.()

      count.set(from)

      const newControls = animate(count, target, {
        ...transition,
        onComplete: () => {
          onComplete?.()
        },
      })
      setControls(newControls)
    }, [])

    // Expose the startAnimation function via ref
    useImperativeHandle(ref, () => ({
      startAnimation,
    }))

    useEffect(() => {
      if (autoStart) {
        startAnimation()
      }
      return () => controls?.stop()
    }, [autoStart])

    return (
      <motion.span className={cn(className)} {...props}>
        {rounded}
      </motion.span>
    )
  }
)

NumberTicker.displayName = "NumberTicker"

export default NumberTicker

// Usage example:
// To start the animation from outside the component:
// 1. Create a ref:
//    const tickerRef = useRef<NumberTickerRef>(null);
// 2. Pass the ref to the NumberTicker component:
//    <NumberTicker ref={tickerRef} from={0} target={100} autoStart={false} />
// 3. Call the startAnimation function:
//    tickerRef.current?.startAnimation();
src/fancy/components/text/breathing-text.tsx
파일 저장

"use client"

import { ElementType } from "react"
import { motion, Transition, Variants } from "motion/react"

import { cn } from "@/lib/utils"

interface TextProps extends React.HTMLAttributes<HTMLElement> {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   */
  as?: ElementType

  /**
   * Initial font variation settings
   */
  fromFontVariationSettings: string

  /**
   * Target font variation settings to animate to
   */
  toFontVariationSettings: string

  /**
   * Animation transition configuration
   * @default { duration: 1.5, ease: "easeInOut" }
   */
  transition?: Transition

  /**
   * Duration of stagger delay between elements in seconds
   * @default 0.1
   */
  staggerDuration?: number

  /**
   * Direction to stagger animations from
   * @default "first"
   */
  staggerFrom?: "first" | "last" | "center" | number

  /**
   * Delay between animation repeats in seconds
   * @default 0.1
   */
  repeatDelay?: number
}

const BreathingText = ({
  children,
  as = "span",
  fromFontVariationSettings,
  toFontVariationSettings,
  transition = {
    duration: 1.5,
    ease: "easeInOut",
  },
  staggerDuration = 0.1,
  staggerFrom = "first",
  repeatDelay = 0.1,
  className,
  ...props
}: TextProps) => {
  const letterVariants: Variants = {
    initial: { fontVariationSettings: fromFontVariationSettings },
    animate: (i) => ({
      fontVariationSettings: toFontVariationSettings,
      transition: {
        ...transition,
        repeat: Infinity,
        repeatType: "mirror",
        delay: i * staggerDuration,
        repeatDelay: repeatDelay,
      },
    }),
  }

  const getCustomIndex = (index: number, total: number) => {
    if (typeof staggerFrom === "number") {
      return Math.abs(index - staggerFrom)
    }
    switch (staggerFrom) {
      case "first":
        return index
      case "last":
        return total - 1 - index
      case "center":
      default:
        return Math.abs(index - Math.floor(total / 2))
    }
  }

  const letters = String(children).split("")
  const ElementTag = as

  return (
    <ElementTag
      className={cn(
        className,
        // an after pseudo element is used to create a container large enough to hold the text with full weight. Helps avoid layout shifts
        "relative after:absolute after:content-[attr(data-text)] after:font-black after:pointer-none after:overflow-hidden after:select-none after:invisible after:h-0"
      )}
      {...props}
      data-text={children}
    >
      {letters.map((letter: string, i: number) => (
        <motion.span
          key={i}
          className="inline-block whitespace-pre"
          aria-hidden="true"
          variants={letterVariants}
          initial="initial"
          animate="animate"
          custom={getCustomIndex(i, letters.length)}
        >
          {letter}
        </motion.span>
      ))}
      <span className="sr-only">{children}</span>
    </ElementTag>
  )
}

export default BreathingText
src/fancy/components/text/letter-3d-swap.tsx
파일 저장

"use client"

import React, { ElementType, useCallback, useMemo, useState } from "react"
import {
  AnimationOptions,
  useAnimate,
  ValueAnimationTransition,
} from "motion/react"

import { cn } from "@/lib/utils"

// handy function to split text into characters with support for unicode and emojis
const splitIntoCharacters = (text: string): string[] => {
  if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
    const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
    return Array.from(segmenter.segment(text), ({ segment }) => segment)
  }
  // Fallback for browsers that don't support Intl.Segmenter
  return Array.from(text)
}

// handy function to extract text from children
const extractTextFromChildren = (children: React.ReactNode): string | undefined => {
  // Handle null/undefined
  if (children == null) return ""

  // Handle string
  if (typeof children === "string") return children

  // Handle number
  if (typeof children === "number") return String(children)

  // Handle arrays (including fragments)
  if (Array.isArray(children)) {
    return children.map(extractTextFromChildren).join("")
  }

  // Handle React elements
  if (React.isValidElement(children)) {
    const props = (children as React.ReactElement).props
    const childText = (props as any).children as React.ReactNode

    // Recursively extract text from children
    if (childText != null) {
      return extractTextFromChildren(childText)
    }

    return ""
  }
}

/**
 * Internal helper interface for representing a word in the text with its characters and spacing information
 */
interface WordObject {
  /**
   * Array of individual characters in the word
   */
  characters: string[]
  /**
   * Whether this word needs a space after it
   */
  needsSpace: boolean
}

interface Letter3DSwapProps {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   */
  as?: ElementType
  /**
   * Class name for the main container element.
   */
  mainClassName?: string

  /**
   * Class name for the front face element.
   */
  frontFaceClassName?: string

  /**
   * Class name for the secondary face element.
   */
  secondFaceClassName?: string

  /**
   * Duration of stagger delay between elements in seconds.
   * @default 0.05
   */
  staggerDuration?: number

  /**
   * Direction to stagger animations from.
   * @default "first"
   */
  staggerFrom?: "first" | "last" | "center" | number | "random"

  /**
   * Animation transition configuration.
   * @default { type: "spring", damping: 25, stiffness: 300 }
   */
  transition?: ValueAnimationTransition | AnimationOptions

  /**
   * Direction of rotation
   * @default "right"
   */
  rotateDirection?: "top" | "right" | "bottom" | "left"
}

const Letter3DSwap = ({
  children,
  as = "p",
  mainClassName,
  frontFaceClassName,
  secondFaceClassName,
  staggerDuration = 0.05,
  staggerFrom = "first",
  transition = { type: "spring", damping: 30, stiffness: 300 },
  rotateDirection = "right",
  ...props
}: Letter3DSwapProps) => {
  const [isAnimating, setIsAnimating] = useState(false)
  const [isHovering, setIsHovering] = useState(false)
  const [scope, animate] = useAnimate()

  // Determine rotation transform based on direction
  const rotationTransform = (() => {
    switch (rotateDirection) {
      case "top":
        return "rotateX(90deg)"
      case "right":
        return "rotateY(90deg)"
      case "bottom":
        return "rotateX(-90deg)"
      case "left":
        return "rotateY(90deg)"
      default:
        return "rotateY(-90deg)"
    }
  })()

  // Convert children to string for processing with error handling
  const text = useMemo(() => {
    try {
      return extractTextFromChildren(children)
    } catch (error) {
      console.error(error)
      return ""
    }
  }, [children])

  // Splitting the text into animation segments
  const characters = useMemo(() => {
    const t = text?.split(" ") ?? []
    const result = t.map((word: string, i: number) => ({
      characters: splitIntoCharacters(word),
      needsSpace: i !== t.length - 1,
    }))
    return result
  }, [text])

  // Helper function to calculate stagger delay for each text segment
  const getStaggerDelay = useCallback(
    (index: number, totalChars: number) => {
      const total = totalChars
      if (staggerFrom === "first") return index * staggerDuration
      if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
      if (staggerFrom === "center") {
        const center = Math.floor(total / 2)
        return Math.abs(center - index) * staggerDuration
      }
      if (staggerFrom === "random") {
        const randomIndex = Math.floor(Math.random() * total)
        return Math.abs(randomIndex - index) * staggerDuration
      }
      return Math.abs(staggerFrom - index) * staggerDuration
    },
    [staggerFrom, staggerDuration]
  )

  // Handle hover start - trigger the rotation
  const handleHoverStart = useCallback(async () => {
    if (isAnimating || isHovering) return

    setIsHovering(true)
    setIsAnimating(true)

    const totalChars = characters.reduce(
      (sum: number, word: WordObject) => sum + word.characters.length,
      0
    )

    // Create delays array based on staggerFrom
    const delays = Array.from({ length: totalChars }, (_, i) => {
      return getStaggerDelay(i, totalChars)
    })

    // Animate each character with its specific delay
    await animate(
      ".letter-3d-swap-char-box-item",
      { transform: rotationTransform },
      {
        ...transition,
        delay: (i: number) => delays[i],
      }
    )

    // Reset all boxes
    await animate(
      ".letter-3d-swap-char-box-item",
      { transform: "rotateX(0deg) rotateY(0deg)" },
      { duration: 0 }
    )

    setIsAnimating(false)
  }, [
    isAnimating,
    isHovering,
    characters,
    transition,
    getStaggerDelay,
    rotationTransform,
    animate,
  ])

  // Handle hover end
  const handleHoverEnd = useCallback(() => {
    setIsHovering(false)
  }, [])

  const ElementTag = as ?? "p"

  return (
    <ElementTag
      className={cn("flex flex-wrap relative", mainClassName)}
      onMouseEnter={handleHoverStart}
      onMouseLeave={handleHoverEnd}
      ref={scope}
      {...props}
    >
      <span className="sr-only">{text}</span>

      {characters.map(
        (wordObj: WordObject, wordIndex: number, array: WordObject[]) => {
          const previousCharsCount = array
            .slice(0, wordIndex)
            .reduce(
              (sum: number, word: WordObject) => sum + word.characters.length,
              0
            )

          return (
            <span key={wordIndex} className="inline-flex">
              {wordObj.characters.map((char: string, charIndex: number) => {
                const totalIndex = previousCharsCount + charIndex

                return (
                  <CharBox
                    key={totalIndex}
                    char={char}
                    frontFaceClassName={frontFaceClassName}
                    secondFaceClassName={secondFaceClassName}
                    rotateDirection={rotateDirection}
                  />
                )
              })}
              {wordObj.needsSpace && <span className="whitespace-pre"> </span>}
            </span>
          )
        }
      )}
    </ElementTag>
  )
}

interface CharBoxProps {
  char: string
  frontFaceClassName?: string
  secondFaceClassName?: string
  rotateDirection: "top" | "right" | "bottom" | "left"
}

const CharBox = ({
  char,
  frontFaceClassName,
  secondFaceClassName,
  rotateDirection,
}: CharBoxProps) => {
  // Get the transform for the second face based on rotation direction
  const getSecondFaceTransform = () => {
    switch (rotateDirection) {
      case "top":
        return `rotateX(-90deg) translateZ(0.5lh)`
      case "right":
        return `rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%) rotateY(-90deg) translateX(50%)`
      case "bottom":
        return `rotateX(90deg) translateZ(0.5lh)`
      case "left":
        return `rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(50%) rotateY(-90deg) translateX(50%)`
      default:
        return `rotateY(90deg) translateZ(1ch)`
    }
  }

  const secondFaceTransform = getSecondFaceTransform()

  return (
    <span
      className="letter-3d-swap-char-box-item inline-box transform-3d"
      style={{
        transform:
          rotateDirection === "top" || rotateDirection === "bottom"
            ? "translateZ(-0.5lh)"
            : "rotateY(90deg) translateX(50%) rotateY(-90deg)",
      }}
    >
      {/* Front face */}
      <span
        className={cn("relative backface-hidden h-[1lh]", frontFaceClassName)}
        style={{
          transform: `${
            rotateDirection === "top" || rotateDirection === "bottom"
              ? "translateZ(0.5lh)"
              : rotateDirection === "left"
                ? "rotateY(90deg) translateX(50%) rotateY(-90deg)"
                : "rotateY(-90deg) translateX(50%) rotateY(90deg)"
          }`,
        }}
      >
        {char}
      </span>

      {/* Second face - positioned based on rotation direction */}
      <span
        className={cn(
          "absolute backface-hidden h-[1lh] top-0 left-0",
          secondFaceClassName
        )}
        style={{
          transform: secondFaceTransform,
        }}
      >
        {char}
      </span>
    </span>
  )
}

Letter3DSwap.displayName = "Letter3DSwap"

export default Letter3DSwap
src/fancy/components/text/letter-swap-forward-anim.tsx
파일 저장

"use client"

import { useState } from "react"
import { AnimationOptions, motion, stagger, useAnimate } from "motion/react"

interface TextProps {
  label: string
  reverse?: boolean
  transition?: AnimationOptions
  staggerDuration?: number
  staggerFrom?: "first" | "last" | "center" | number
  className?: string
  onClick?: () => void
}

const LetterSwapForward = ({
  label,
  reverse = true,
  transition = {
    type: "spring",
    duration: 0.7,
  },
  staggerDuration = 0.03,
  staggerFrom = "first",
  className,
  onClick,
  ...props
}: TextProps) => {
  const [scope, animate] = useAnimate()
  const [blocked, setBlocked] = useState(false)

  const hoverStart = () => {
    if (blocked) return

    setBlocked(true)

    // Function to merge user transition with stagger and delay
    const mergeTransition = (baseTransition: AnimationOptions) => ({
      ...baseTransition,
      delay: stagger(staggerDuration, {
        from: staggerFrom,
      }),
    })

    animate(
      ".letter",
      { y: reverse ? "100%" : "-100%" },
      mergeTransition(transition)
    ).then(() => {
      animate(
        ".letter",
        {
          y: 0,
        },
        {
          duration: 0,
        }
      ).then(() => {
        setBlocked(false)
      })
    })

    animate(
      ".letter-secondary",
      {
        top: "0%",
      },
      mergeTransition(transition)
    ).then(() => {
      animate(
        ".letter-secondary",
        {
          top: reverse ? "-100%" : "100%",
        },
        {
          duration: 0,
        }
      )
    })
  }

  return (
    <span
      className={`flex justify-center items-center relative overflow-hidden  ${className} `}
      onMouseEnter={hoverStart}
      onClick={onClick}
      ref={scope}
      {...props}
    >
      <span className="sr-only">{label}</span>

      {label.split("").map((letter: string, i: number) => {
        return (
          <span
            className="whitespace-pre relative flex"
            key={i}
            aria-hidden={true}
          >
            <motion.span className={`relative letter`} style={{ top: 0 }}>
              {letter}
            </motion.span>
            <motion.span
              className="absolute letter-secondary "
              style={{ top: reverse ? "-100%" : "100%" }}
            >
              {letter}
            </motion.span>
          </span>
        )
      })}
    </span>
  )
}

export default LetterSwapForward
src/fancy/components/text/letter-swap-pingpong-anim.tsx
파일 저장

"use client"

import { useState } from "react"
import { debounce } from "lodash"
import { AnimationOptions, motion, stagger, useAnimate } from "motion/react"

interface TextProps {
  label: string
  reverse?: boolean
  transition?: AnimationOptions
  staggerDuration?: number
  staggerFrom?: "first" | "last" | "center" | number
  className?: string
  onClick?: () => void
}

const LetterSwapPingPong = ({
  label,
  reverse = true,
  transition = {
    type: "spring",
    duration: 0.7,
  },
  staggerDuration = 0.03,
  staggerFrom = "first",
  className,
  onClick,
  ...props
}: TextProps) => {
  const [scope, animate] = useAnimate()
  const [isHovered, setIsHovered] = useState(false)

  const mergeTransition = (baseTransition: AnimationOptions) => ({
    ...baseTransition,
    delay: stagger(staggerDuration, {
      from: staggerFrom,
    }),
  })

  const hoverStart = debounce(
    () => {
      if (isHovered) return
      setIsHovered(true)

      animate(
        ".letter",
        { y: reverse ? "100%" : "-100%" },
        mergeTransition(transition)
      )

      animate(
        ".letter-secondary",
        {
          top: "0%",
        },
        mergeTransition(transition)
      )
    },
    100,
    { leading: true, trailing: true }
  )

  const hoverEnd = debounce(
    () => {
      setIsHovered(false)

      animate(
        ".letter",
        {
          y: 0,
        },
        mergeTransition(transition)
      )

      animate(
        ".letter-secondary",
        {
          top: reverse ? "-100%" : "100%",
        },
        mergeTransition(transition)
      )
    },
    100,
    { leading: true, trailing: true }
  )

  return (
    <motion.span
      className={`flex justify-center items-center relative overflow-hidden  ${className} `}
      onHoverStart={hoverStart}
      onHoverEnd={hoverEnd}
      onClick={onClick}
      ref={scope}
      {...props}
    >
      <span className="sr-only">{label}</span>

      {label.split("").map((letter: string, i: number) => {
        return (
          <span
            className="whitespace-pre relative flex"
            key={i}
            aria-hidden={true}
          >
            <motion.span className={`relative letter`} style={{ top: 0 }}>
              {letter}
            </motion.span>
            <motion.span
              className="absolute letter-secondary "
              style={{ top: reverse ? "-100%" : "100%" }}
            >
              {letter}
            </motion.span>
          </span>
        )
      })}
    </motion.span>
  )
}

export default LetterSwapPingPong
src/fancy/components/text/random-letter-swap-forward-anim.tsx
파일 저장

"use client"

import { useState } from "react"
import { debounce } from "lodash"
import { AnimationOptions, motion, useAnimate } from "motion/react"

interface TextProps {
  label: string
  reverse?: boolean
  transition?: AnimationOptions
  staggerDuration?: number
  className?: string
  onClick?: () => void
}

const RandomLetterSwapForward = ({
  label,
  reverse = true,
  transition = {
    type: "spring",
    duration: 0.8,
  },
  staggerDuration = 0.02,
  className,
  onClick,
  ...props
}: TextProps) => {
  const [scope, animate] = useAnimate()
  const [blocked, setBlocked] = useState(false)

  const mergeTransition = (transition: AnimationOptions, i: number) => ({
    ...transition,
    delay: i * staggerDuration,
  })

  const shuffledIndices = Array.from(
    { length: label.length },
    (_, i) => i
  ).sort(() => Math.random() - 0.5)

  const hoverStart = debounce(
    () => {
      if (blocked) return
      setBlocked(true)

      for (let i = 0; i < label.length; i++) {
        const randomIndex = shuffledIndices[i]
        animate(
          ".letter-" + randomIndex,
          {
            y: reverse ? "100%" : "-100%",
          },
          mergeTransition(transition, i)
        ).then(() => {
          animate(
            ".letter-" + randomIndex,
            {
              y: 0,
            },
            {
              duration: 0,
            }
          )
        })

        animate(
          ".letter-secondary-" + randomIndex,
          {
            top: "0%",
          },
          mergeTransition(transition, i)
        )
          .then(() => {
            animate(
              ".letter-secondary-" + randomIndex,
              {
                top: reverse ? "-100%" : "100%",
              },
              {
                duration: 0,
              }
            )
          })
          .then(() => {
            if (i === label.length - 1) {
              setBlocked(false)
            }
          })
      }
    },
    100,
    { leading: true, trailing: true }
  )

  return (
    <motion.span
      className={`flex justify-center items-center relative overflow-hidden ${className}`}
      onHoverStart={hoverStart}
      onClick={onClick}
      ref={scope}
      {...props}
    >
      <span className="sr-only">{label}</span>

      {label.split("").map((letter: string, i: number) => {
        return (
          <span
            className="whitespace-pre relative flex"
            key={i}
            aria-hidden={true}
          >
            <motion.span
              className={`relative pb-2 letter-${i}`}
              style={{ top: 0 }}
            >
              {letter}
            </motion.span>
            <motion.span
              className={`absolute letter-secondary-${i}`}
              style={{ top: reverse ? "-100%" : "100%" }}
            >
              {letter}
            </motion.span>
          </span>
        )
      })}
    </motion.span>
  )
}

export default RandomLetterSwapForward
src/fancy/components/text/random-letter-swap-pingpong-anim.tsx
파일 저장

"use client"

import { useState } from "react"
import { debounce } from "lodash"
import { AnimationOptions, motion, useAnimate } from "motion/react"

interface TextProps {
  label: string
  reverse?: boolean
  transition?: AnimationOptions
  staggerDuration?: number
  className?: string
  onClick?: () => void
}

const RandomLetterSwapPingPong = ({
  label,
  reverse = true,
  transition = {
    type: "spring",
    duration: 0.8,
  },
  staggerDuration = 0.02,
  className,
  onClick,
  ...props
}: TextProps) => {
  const [scope, animate] = useAnimate()
  const [blocked, setBlocked] = useState(false)

  const mergeTransition = (transition: AnimationOptions, i: number) => ({
    ...transition,
    delay: i * staggerDuration,
  })

  const shuffledIndices = Array.from(
    { length: label.length },
    (_, i) => i
  ).sort(() => Math.random() - 0.5)

  const hoverStart = debounce(
    () => {
      if (blocked) return
      setBlocked(true)

      for (let i = 0; i < label.length; i++) {
        const randomIndex = shuffledIndices[i]
        animate(
          ".letter-" + randomIndex,
          {
            y: reverse ? "100%" : "-100%",
          },
          mergeTransition(transition, i)
        )

        animate(
          ".letter-secondary-" + randomIndex,
          {
            top: "0%",
          },
          mergeTransition(transition, i)
        )
      }
    },
    100,
    { leading: true, trailing: true }
  )

  const hoverEnd = debounce(
    () => {
      setBlocked(false)

      for (let i = 0; i < label.length; i++) {
        const randomIndex = shuffledIndices[i]
        animate(
          ".letter-" + randomIndex,
          {
            y: 0,
          },
          mergeTransition(transition, i)
        )

        animate(
          ".letter-secondary-" + randomIndex,
          {
            top: reverse ? "-100%" : "100%",
          },
          mergeTransition(transition, i)
        )
      }
    },
    100,
    { leading: true, trailing: true }
  )

  return (
    <motion.span
      className={`flex justify-center items-center relative overflow-hidden  ${className} `}
      onHoverStart={hoverStart}
      onHoverEnd={hoverEnd}
      onClick={onClick}
      ref={scope}
      {...props}
    >
      <span className="sr-only">{label}</span>

      {label.split("").map((letter: string, i: number) => {
        return (
          <span
            className="whitespace-pre relative flex"
            key={i}
            aria-hidden={true}
          >
            <motion.span
              className={`relative pb-2 letter-${i}`}
              style={{ top: 0 }}
            >
              {letter}
            </motion.span>
            <motion.span
              className={`absolute letter-secondary-${i}`}
              style={{ top: reverse ? "-100%" : "100%" }}
            >
              {letter}
            </motion.span>
          </span>
        )
      })}
    </motion.span>
  )
}

export default RandomLetterSwapPingPong
src/fancy/components/text/scramble-hover.tsx
파일 저장

"use client"

import { useEffect, useState } from "react"
import { motion } from "motion/react"

import { cn } from "@/lib/utils"

interface ScrambleHoverProps {
  text: string
  scrambleSpeed?: number
  maxIterations?: number
  sequential?: boolean
  revealDirection?: "start" | "end" | "center"
  useOriginalCharsOnly?: boolean
  characters?: string
  className?: string
  scrambledClassName?: string
}

const ScrambleHover: React.FC<ScrambleHoverProps> = ({
  text,
  scrambleSpeed = 50,
  maxIterations = 10,
  useOriginalCharsOnly = false,
  characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
  className,
  scrambledClassName,
  sequential = false,
  revealDirection = "start",
  ...props
}) => {
  const [displayText, setDisplayText] = useState(text)
  const [isHovering, setIsHovering] = useState(false)
  const [isScrambling, setIsScrambling] = useState(false)
  const [revealedIndices, setRevealedIndices] = useState(new Set<number>())

  useEffect(() => {
    let interval: NodeJS.Timeout
    let currentIteration = 0

    const getNextIndex = () => {
      const textLength = text.length
      switch (revealDirection) {
        case "start":
          return revealedIndices.size
        case "end":
          return textLength - 1 - revealedIndices.size
        case "center":
          const middle = Math.floor(textLength / 2)
          const offset = Math.floor(revealedIndices.size / 2)
          const nextIndex =
            revealedIndices.size % 2 === 0
              ? middle + offset
              : middle - offset - 1

          if (
            nextIndex >= 0 &&
            nextIndex < textLength &&
            !revealedIndices.has(nextIndex)
          ) {
            return nextIndex
          }

          for (let i = 0; i < textLength; i++) {
            if (!revealedIndices.has(i)) return i
          }
          return 0
        default:
          return revealedIndices.size
      }
    }

    const shuffleText = (text: string) => {
      if (useOriginalCharsOnly) {
        const positions = text.split("").map((char, i) => ({
          char,
          isSpace: char === " ",
          index: i,
          isRevealed: revealedIndices.has(i),
        }))

        const nonSpaceChars = positions
          .filter((p) => !p.isSpace && !p.isRevealed)
          .map((p) => p.char)

        // Shuffle remaining non-revealed, non-space characters
        for (let i = nonSpaceChars.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1))
          ;[nonSpaceChars[i], nonSpaceChars[j]] = [
            nonSpaceChars[j],
            nonSpaceChars[i],
          ]
        }

        let charIndex = 0
        return positions
          .map((p) => {
            if (p.isSpace) return " "
            if (p.isRevealed) return text[p.index]
            return nonSpaceChars[charIndex++]
          })
          .join("")
      } else {
        return text
          .split("")
          .map((char, i) => {
            if (char === " ") return " "
            if (revealedIndices.has(i)) return text[i]
            return availableChars[
              Math.floor(Math.random() * availableChars.length)
            ]
          })
          .join("")
      }
    }

    const availableChars = useOriginalCharsOnly
      ? Array.from(new Set(text.split(""))).filter((char) => char !== " ")
      : characters.split("")

    if (isHovering) {
      setIsScrambling(true)
      interval = setInterval(() => {
        if (sequential) {
          if (revealedIndices.size < text.length) {
            const nextIndex = getNextIndex()
            revealedIndices.add(nextIndex)
            setDisplayText(shuffleText(text))
          } else {
            clearInterval(interval)
            setIsScrambling(false)
          }
        } else {
          setDisplayText(shuffleText(text))
          currentIteration++
          if (currentIteration >= maxIterations) {
            clearInterval(interval)
            setIsScrambling(false)
            setDisplayText(text)
          }
        }
      }, scrambleSpeed)
    } else {
      setDisplayText(text)
      revealedIndices.clear()
    }

    return () => {
      if (interval) clearInterval(interval)
    }
  }, [
    isHovering,
    text,
    characters,
    scrambleSpeed,
    useOriginalCharsOnly,
    sequential,
    revealDirection,
    maxIterations,
  ])

  return (
    <motion.span
      onHoverStart={() => setIsHovering(true)}
      onHoverEnd={() => setIsHovering(false)}
      className={cn("inline-block whitespace-pre-wrap", className)}
      {...props}
    >
      <span className="sr-only">{displayText}</span>
      <span aria-hidden="true">
        {displayText.split("").map((char, index) => (
          <span
            key={index}
            className={cn(
              revealedIndices.has(index) || !isScrambling || !isHovering
                ? className
                : scrambledClassName
            )}
          >
            {char}
          </span>
        ))}
      </span>
    </motion.span>
  )
}

export default ScrambleHover
src/fancy/components/text/scramble-in.tsx
파일 저장

"use client"

import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useState,
} from "react"

interface ScrambleInProps {
  text: string
  scrambleSpeed?: number
  scrambledLetterCount?: number
  characters?: string
  className?: string
  scrambledClassName?: string
  autoStart?: boolean
  onStart?: () => void
  onComplete?: () => void
}

export interface ScrambleInHandle {
  start: () => void
  reset: () => void
}

const ScrambleIn = forwardRef<ScrambleInHandle, ScrambleInProps>(
  (
    {
      text,
      scrambleSpeed = 50,
      scrambledLetterCount = 2,
      characters = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
      className = "",
      scrambledClassName = "",
      autoStart = true,
      onStart,
      onComplete,
    },
    ref
  ) => {
    const [displayText, setDisplayText] = useState("")
    const [isAnimating, setIsAnimating] = useState(false)
    const [visibleLetterCount, setVisibleLetterCount] = useState(0)
    const [scrambleOffset, setScrambleOffset] = useState(0)

    const startAnimation = useCallback(() => {
      setIsAnimating(true)
      setVisibleLetterCount(0)
      setScrambleOffset(0)
      onStart?.()
    }, [onStart])

    const reset = useCallback(() => {
      setIsAnimating(false)
      setVisibleLetterCount(0)
      setScrambleOffset(0)
      setDisplayText("")
    }, [])

    useImperativeHandle(ref, () => ({
      start: startAnimation,
      reset,
    }))

    useEffect(() => {
      if (autoStart) {
        startAnimation()
      }
    }, [autoStart, startAnimation])

    useEffect(() => {
      let interval: NodeJS.Timeout

      if (isAnimating) {
        interval = setInterval(() => {
          // Increase visible text length
          if (visibleLetterCount < text.length) {
            setVisibleLetterCount((prev) => prev + 1)
          }
          // Start sliding scrambled text out
          else if (scrambleOffset < scrambledLetterCount) {
            setScrambleOffset((prev) => prev + 1)
          }
          // Complete animation
          else {
            clearInterval(interval)
            setIsAnimating(false)
            onComplete?.()
          }

          // Calculate how many scrambled letters we can show
          const remainingSpace = Math.max(0, text.length - visibleLetterCount)
          const currentScrambleCount = Math.min(
            remainingSpace,
            scrambledLetterCount
          )

          // Generate scrambled text
          const scrambledPart = Array(currentScrambleCount)
            .fill(0)
            .map(
              () => characters[Math.floor(Math.random() * characters.length)]
            )
            .join("")

          setDisplayText(text.slice(0, visibleLetterCount) + scrambledPart)
        }, scrambleSpeed)
      }

      return () => {
        if (interval) clearInterval(interval)
      }
    }, [
      isAnimating,
      text,
      visibleLetterCount,
      scrambleOffset,
      scrambledLetterCount,
      characters,
      scrambleSpeed,
      onComplete,
    ])

    const renderText = () => {
      const revealed = displayText.slice(0, visibleLetterCount)
      const scrambled = displayText.slice(visibleLetterCount)

      return (
        <>
          <span className={className}>{revealed}</span>
          <span className={scrambledClassName}>{scrambled}</span>
        </>
      )
    }

    return (
      <>
        <span className="sr-only">{text}</span>
        <span className="inline-block whitespace-pre-wrap" aria-hidden="true">
          {renderText()}
        </span>
      </>
    )
  }
)

ScrambleIn.displayName = "ScrambleIn"
export default ScrambleIn
src/fancy/components/text/scroll-and-swap-text.tsx
파일 저장

"use client"

import React, { ElementType, useMemo, useRef } from "react"
import { motion, useScroll, useTransform, useSpring } from "motion/react"
import { cn } from "@/lib/utils"

// handy function to extract text from children
const extractTextFromChildren = (children: React.ReactNode): string | undefined => {
  // Handle null/undefined
  if (children == null) return ""

  // Handle string
  if (typeof children === "string") return children

  // Handle number
  if (typeof children === "number") return String(children)

  // Handle arrays (including fragments)
  if (Array.isArray(children)) {
    return children.map(extractTextFromChildren).join("")
  }

  // Handle React elements
  if (React.isValidElement(children)) {
    const props = (children as React.ReactElement).props
    const childText = (props as any).children as React.ReactNode

    // Recursively extract text from children
    if (childText != null) {
      return extractTextFromChildren(childText)
    }

    return ""
  }
}

interface ScrollAndSwapTextProps {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   * @default "span"
   */
  as?: ElementType

  /**
   * Reference to the container element for scroll tracking
   */
  containerRef: React.RefObject<HTMLElement | null>

  /**
   * Offset configuration for when the animation should start and end relative to the scroll container. Check motion documentation for more details.
   * @default ["0 0", "0 1"]
   */
  offset?: [string, string]

  /**
   * Additional CSS classes for styling the component
   */
  className?: string

  /**
   * Spring animation configuration for smoothing the scroll-based animation
   * @default { stiffness: 200, damping: 30 }
   */
  springConfig?: {
    stiffness?: number
    damping?: number
    mass?: number
  }
}

/**
 * ScrollAndSwapText creates a scroll-triggered text animation where text slides vertically
 * based on scroll progress.
 */
const ScrollAndSwapText = ({
  children,
  as = "span",
  offset = ["0 0", "0 1"],
  className,
  containerRef,
  springConfig = { stiffness: 200, damping: 30 },
  ...props
}: ScrollAndSwapTextProps) => {
  const ref = useRef<HTMLElement>(null)

  // Convert children to string for processing with error handling
  const text = useMemo(() => {
    try {
      return extractTextFromChildren(children)
    } catch (error) {
      console.error(error)
      return ""
    }
  }, [children])

  // Track scroll progress within the specified container and target element
  const { scrollYProgress } = useScroll({
    container: containerRef,
    target: ref,
    offset: offset as any, // framer motion doesnt export the type, so we have to cast it, sorry :/
  })

  // Apply spring physics to smooth the scroll-based animation
  const springScrollYProgress = useSpring(scrollYProgress, springConfig)

  // Transform scroll progress into vertical translation values
  // Original text moves from 0% to -100% (slides up and out)
  const top = useTransform(springScrollYProgress, [0, 1], ["0%", "-100%"])
  // Replacement text moves from 100% to 0% (slides up from below)
  const bottom = useTransform(springScrollYProgress, [0, 1], ["100%", "0%"])

  const ElementTag = as

  return (
    <ElementTag
      className={cn("flex overflow-hidden relative items-center justify-center p-0", className)}
      ref={ref}
      {...props}
    >

      <span className="relative text-transparent" aria-hidden="true">
        {text}
      </span>
      
      <motion.span className="absolute" style={{ top: top }}>
        {text}
      </motion.span>
      
      <motion.span
        className="absolute"
        style={{ top: bottom }}
        aria-hidden="true"
      >
        {text}
      </motion.span>
    </ElementTag>
  )
}

ScrollAndSwapText.displayName = "ScrollAndSwapText"

export default ScrollAndSwapText
src/fancy/components/text/text-along-path.tsx
파일 저장

import { RefObject, useEffect, useRef } from "react"
import { useScroll, UseScrollOptions, useTransform } from "motion/react"

type PreserveAspectRatioAlign =
  | "none"
  | "xMinYMin"
  | "xMidYMin"
  | "xMaxYMin"
  | "xMinYMid"
  | "xMidYMid"
  | "xMaxYMid"
  | "xMinYMax"
  | "xMidYMax"
  | "xMaxYMax"

type PreserveAspectRatioMeetOrSlice = "meet" | "slice"

type PreserveAspectRatio =
  | PreserveAspectRatioAlign
  | `${Exclude<PreserveAspectRatioAlign, "none">} ${PreserveAspectRatioMeetOrSlice}`

interface AnimatedPathTextProps {
  // Path properties
  path: string
  pathId?: string
  pathClassName?: string
  preserveAspectRatio?: PreserveAspectRatio
  showPath?: boolean

  // SVG properties
  width?: string | number
  height?: string | number
  viewBox?: string
  svgClassName?: string

  // Text properties
  text: string
  textClassName?: string
  textAnchor?: "start" | "middle" | "end"

  // Animation properties
  animationType?: "auto" | "scroll"

  // Animation properties if animationType is auto
  duration?: number
  repeatCount?: number | "indefinite"
  easingFunction?: {
    calcMode?: string
    keyTimes?: string
    keySplines?: string
  }

  // Scroll animation properties if animationType is scroll
  scrollContainer?: RefObject<HTMLElement | null>
  scrollOffset?: UseScrollOptions["offset"]
  scrollTransformValues?: [number, number]
}

const AnimatedPathText = ({
  // Path defaults
  path,
  pathId,
  pathClassName,
  preserveAspectRatio = "xMidYMid meet",
  showPath = false,

  // SVG defaults
  width = "100%",
  height = "100%",
  viewBox = "0 0 100 100",
  svgClassName,

  // Text defaults
  text,
  textClassName,
  textAnchor = "start",

  // Animation type
  animationType = "auto",

  // Animation defaults
  duration = 4,
  repeatCount = "indefinite",

  easingFunction = {},

  // Scroll animation defaults
  scrollContainer,
  scrollOffset = ["start end", "end end"],
  scrollTransformValues = [0, 100],
}: AnimatedPathTextProps) => {
  const textPathRefs = useRef<SVGTextPathElement[]>([])

  // naive id for the path. you should rather use yours :)
  const id =
    pathId || `animated-path-${Math.random().toString(36).substring(7)}`

  const { scrollYProgress } = useScroll({
    ...(scrollContainer && { container: scrollContainer }),
    offset: scrollOffset,
  })

  const t = useTransform(scrollYProgress, [0, 1], scrollTransformValues)

  useEffect(() => {
    // Re-initialize scroll handler when container ref changes
    const handleChange = (e: number) => {
      textPathRefs.current.forEach((textPath) => {
        if (textPath) {
          textPath.setAttribute("startOffset", `${t.get()}%`)
        }
      })
    }

    scrollYProgress.on("change", handleChange)

    return () => {
      scrollYProgress.clearListeners()
    }
  }, [scrollYProgress, t])

  const animationProps =
    animationType === "auto"
      ? {
          from: "0%",
          to: "100%",
          begin: "0s",
          dur: `${duration}s`,
          repeatCount: repeatCount,
          ...(easingFunction && easingFunction),
        }
      : null

  return (
    <svg
      className={svgClassName}
      xmlns="http://www.w3.org/2000/svg"
      width={width}
      height={height}
      viewBox={viewBox}
      preserveAspectRatio={preserveAspectRatio}
    >
      <path
        id={id}
        className={pathClassName}
        d={path}
        stroke={showPath ? "currentColor" : "none"}
        fill="none"
      />

      {/* First text element */}
      <text textAnchor={textAnchor} fill="currentColor">
        <textPath
          className={textClassName}
          href={`#${id}`}
          startOffset={"0%"}
          ref={(ref) => {
            if (ref) textPathRefs.current[0] = ref
          }}
        >
          {animationType === "auto" && (
            <animate attributeName="startOffset" {...animationProps} />
          )}
          {text}
        </textPath>
      </text>

      {/* Second text element (offset to hide the jump) */}
      {animationType === "auto" && (
        <text textAnchor={textAnchor} fill="currentColor">
          <textPath
            className={textClassName}
            href={`#${id}`}
            startOffset={"-100%"}
            ref={(ref) => {
              if (ref) textPathRefs.current[1] = ref
            }}
          >
            {animationType === "auto" && (
              <animate
                attributeName="startOffset"
                {...animationProps}
                from="-100%"
                to="0%"
              />
            )}
            {text}
          </textPath>
        </text>
      )}
    </svg>
  )
}

export default AnimatedPathText
src/fancy/components/text/text-cursor-proximity.tsx
파일 저장

"use client"

import React, { CSSProperties, ElementType, forwardRef, useRef, useMemo } from "react"
import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useTransform,
} from "motion/react"

import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"
import { cn } from "@/lib/utils"

// Helper type that makes all properties of CSSProperties accept number | string
type CSSPropertiesWithValues = {
  [K in keyof CSSProperties]: string | number
}

interface StyleValue<T extends keyof CSSPropertiesWithValues> {
  from: CSSPropertiesWithValues[T]
  to: CSSPropertiesWithValues[T]
}

interface TextProps extends React.HTMLAttributes<HTMLSpanElement> {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   * @default span
   */
  as?: ElementType

  /**
   * Object containing style properties to animate
   * Each property should have 'from' and 'to' values
   */
  styles: Partial<{
    [K in keyof CSSPropertiesWithValues]: StyleValue<K>
  }>

  /**
   * Reference to the container element for mouse position calculations
   */
  containerRef: React.RefObject<HTMLDivElement | null>

  /**
   * Radius of the proximity effect in pixels
   * @default 50
   */
  radius?: number

  /**
   * Type of falloff function to use for the proximity effect
   * @default "linear"
   */
  falloff?: "linear" | "exponential" | "gaussian"
}

const TextCursorProximity = forwardRef<HTMLSpanElement, TextProps>(
  (
    {
      children,
      as,
      styles,
      containerRef,
      radius = 50,
      falloff = "linear",
      className,
      ...props
    },
    ref
  ) => {
    const MotionComponent = useMemo(() => motion.create(as ?? "span"), [as])
    const letterRefs = useRef<(HTMLSpanElement | null)[]>([])
    const mousePositionRef = useMousePositionRef(containerRef)

    // Convert children to string for letter processing
    const text = React.Children.toArray(children).join("")

    // Create a motion value for each letter's proximity
    const letterProximities = useRef(
      Array(text.replace(/\s/g, "").length)
        .fill(0)
        .map(() => useMotionValue(0))
    )

    const calculateDistance = (
      x1: number,
      y1: number,
      x2: number,
      y2: number
    ): number => {
      return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
    }

    const calculateFalloff = (distance: number): number => {
      const normalizedDistance = Math.min(Math.max(1 - distance / radius, 0), 1)

      switch (falloff) {
        case "exponential":
          return Math.pow(normalizedDistance, 2)
        case "gaussian":
          return Math.exp(-Math.pow(distance / (radius / 2), 2) / 2)
        case "linear":
        default:
          return normalizedDistance
      }
    }

    useAnimationFrame(() => {
      if (!containerRef.current) return
      const containerRect = containerRef.current.getBoundingClientRect()

      letterRefs.current.forEach((letterRef, index) => {
        if (!letterRef) return

        const rect = letterRef.getBoundingClientRect()
        const letterCenterX = rect.left + rect.width / 2 - containerRect.left
        const letterCenterY = rect.top + rect.height / 2 - containerRect.top

        const distance = calculateDistance(
          mousePositionRef.current.x,
          mousePositionRef.current.y,
          letterCenterX,
          letterCenterY
        )

        const proximity = calculateFalloff(distance)
        letterProximities.current[index].set(proximity)
      })
    })

    const words = text.split(" ")
    let letterIndex = 0

    return (
      <MotionComponent
        ref={ref}
        className={cn("", className)}
        {...props}
      >
        {words.map((word, wordIndex) => (
          <span
            key={wordIndex}
            className="inline-block"
            aria-hidden={true}
          >
            {word.split("").map((letter) => {
              const currentLetterIndex = letterIndex++
              const proximity = letterProximities.current[currentLetterIndex]

              // Create transformed values for each style property
              const transformedStyles = Object.entries(styles).reduce(
                (acc, [key, value]) => {
                  acc[key] = useTransform(
                    proximity,
                    [0, 1],
                    [value.from, value.to]
                  )
                  return acc
                },
                {} as Record<string, any>
              )

              return (
                <motion.span
                  key={currentLetterIndex}
                  ref={(el: HTMLSpanElement | null) => {
                    letterRefs.current[currentLetterIndex] = el
                  }}
                  className="inline-block"
                  aria-hidden="true"
                  style={transformedStyles}
                >
                  {letter}
                </motion.span>
              )
            })}
            {wordIndex < words.length - 1 && (
              <span className="inline-block">&nbsp;</span>
            )}
          </span>
        ))}
        <span className="sr-only">{text}</span>
      </MotionComponent>
    )
  }
)

TextCursorProximity.displayName = "TextCursorProximity"
export default TextCursorProximity
src/fancy/components/text/text-highlighter.tsx
파일 저장

"use client"

import {
  ElementType,
  forwardRef,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from "react"
import { motion, Transition, useInView, UseInViewOptions } from "motion/react"

import { cn } from "@/lib/utils"

type HighlightDirection = "ltr" | "rtl" | "ttb" | "btt"

type TextHighlighterProps = {
  /**
   * The text content to be highlighted
   */
  children: React.ReactNode

  /**
   * HTML element to render as
   * @default "p"
   */
  as?: ElementType

  /**
   * How to trigger the animation
   * @default "inView"
   */
  triggerType?: "hover" | "ref" | "inView" | "auto"

  /**
   * Animation transition configuration
   * @default { duration: 0.4, type: "spring", bounce: 0 }
   */
  transition?: Transition

  /**
   * Options for useInView hook when triggerType is "inView"
   */
  useInViewOptions?: UseInViewOptions

  /**
   * Class name for the container element
   */
  className?: string

  /**
   * Highlight color (CSS color string). Also can be a function that returns a color string, eg:
   * @default 'hsl(60, 90%, 68%)' (yellow)
   */
  highlightColor?: string

  /**
   * Direction of the highlight animation
   * @default "ltr" (left to right)
   */
  direction?: HighlightDirection
} & React.HTMLAttributes<HTMLElement>

export type TextHighlighterRef = {
  /**
   * Trigger the highlight animation
   * @param direction - Optional direction override for this animation
   */
  animate: (direction?: HighlightDirection) => void

  /**
   * Reset the highlight animation
   */
  reset: () => void
}

export const TextHighlighter = forwardRef<
  TextHighlighterRef,
  TextHighlighterProps
>(
  (
    {
      children,
      as = "span",
      triggerType = "inView",
      transition = { type: "spring", duration: 1, delay: 0, bounce: 0 },
      useInViewOptions = {
        once: true,
        initial: false,
        amount: 0.1,
      },
      className,
      highlightColor = "hsl(25, 90%, 80%)",
      direction = "ltr",
      ...props
    },
    ref
  ) => {
    const componentRef = useRef<HTMLDivElement>(null)
    const [isAnimating, setIsAnimating] = useState(false)
    const [isHovered, setIsHovered] = useState(false)
    const [currentDirection, setCurrentDirection] =
      useState<HighlightDirection>(direction)

    // this allows us to change the direction whenever the direction prop changes
    useEffect(() => {
      setCurrentDirection(direction)
    }, [direction])

    const isInView =
      triggerType === "inView"
        ? useInView(componentRef, useInViewOptions)
        : false

    useImperativeHandle(ref, () => ({
      animate: (animationDirection?: HighlightDirection) => {
        if (animationDirection) {
          setCurrentDirection(animationDirection)
        }
        setIsAnimating(true)
      },
      reset: () => setIsAnimating(false),
    }))

    const shouldAnimate =
      triggerType === "hover"
        ? isHovered
        : triggerType === "inView"
          ? isInView
          : triggerType === "ref"
            ? isAnimating
            : triggerType === "auto"
              ? true
              : false

    const ElementTag = as || "span"

    // Get background size based on direction
    const getBackgroundSize = (animated: boolean) => {
      switch (currentDirection) {
        case "ltr":
          return animated ? "100% 100%" : "0% 100%"
        case "rtl":
          return animated ? "100% 100%" : "0% 100%"
        case "ttb":
          return animated ? "100% 100%" : "100% 0%"
        case "btt":
          return animated ? "100% 100%" : "100% 0%"
        default:
          return animated ? "100% 100%" : "0% 100%"
      }
    }

    // Get background position based on direction
    const getBackgroundPosition = () => {
      switch (currentDirection) {
        case "ltr":
          return "0% 0%"
        case "rtl":
          return "100% 0%"
        case "ttb":
          return "0% 0%"
        case "btt":
          return "0% 100%"
        default:
          return "0% 0%"
      }
    }

    const animatedSize = useMemo(() => getBackgroundSize(shouldAnimate), [shouldAnimate, currentDirection])
    const initialSize = useMemo(() => getBackgroundSize(false), [currentDirection])
    const backgroundPosition = useMemo(() => getBackgroundPosition(), [currentDirection])

    const highlightStyle = {
      backgroundImage: `linear-gradient(${highlightColor}, ${highlightColor})`,
      backgroundRepeat: "no-repeat",
      backgroundPosition: backgroundPosition,
      backgroundSize: animatedSize,
      boxDecorationBreak: "clone",
      WebkitBoxDecorationBreak: "clone",
    } as React.CSSProperties

    return (
      <ElementTag
        ref={componentRef}
        onMouseEnter={() => triggerType === "hover" && setIsHovered(true)}
        onMouseLeave={() => triggerType === "hover" && setIsHovered(false)}
        {...props}
      >
        <motion.span
          className={cn("inline", className)}
          style={highlightStyle}
          animate={{
            backgroundSize: animatedSize,
          }}
          initial={{
            backgroundSize: initialSize,
          }}
          transition={transition}
        >
          {children}
        </motion.span>
      </ElementTag>
    )
  }
)

TextHighlighter.displayName = "TextHighlighter"

export default TextHighlighter
src/fancy/components/text/text-rotate.tsx
파일 저장

"use client"

import {
  ElementType,
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useState,
} from "react"
import {
  AnimatePresence,
  AnimatePresenceProps,
  motion,
  MotionProps,
  Transition,
} from "motion/react"

import { cn } from "@/lib/utils"

// handy function to split text into characters with support for unicode and emojis
const splitIntoCharacters = (text: string): string[] => {
  if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
    const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
    return Array.from(segmenter.segment(text), ({ segment }) => segment)
  }
  // Fallback for browsers that don't support Intl.Segmenter
  return Array.from(text)
}

interface TextRotateProps {
  /**
   * Array of text strings to rotate through.
   * Required prop with no default value.
   */
  texts: string[]

  /**
   * render as HTML Tag
   */
  as?: ElementType

  /**
   * Time in milliseconds between text rotations.
   * @default 2000
   */
  rotationInterval?: number

  /**
   * Initial animation state or array of states.
   * @default { y: "100%", opacity: 0 }
   */
  initial?: MotionProps["initial"] | MotionProps["initial"][]

  /**
   * Animation state to animate to or array of states.
   * @default { y: 0, opacity: 1 }
   */
  animate?: MotionProps["animate"] | MotionProps["animate"][]

  /**
   * Animation state when exiting or array of states.
   * @default { y: "-120%", opacity: 0 }
   */
  exit?: MotionProps["exit"] | MotionProps["exit"][]

  /**
   * AnimatePresence mode
   * @default "wait"
   */
  animatePresenceMode?: AnimatePresenceProps["mode"]

  /**
   * Whether to run initial animation on first render.
   * @default false
   */
  animatePresenceInitial?: boolean

  /**
   * Duration of stagger delay between elements in seconds.
   * @default 0
   */
  staggerDuration?: number

  /**
   * Direction to stagger animations from.
   * @default "first"
   */
  staggerFrom?: "first" | "last" | "center" | number | "random"

  /**
   * Animation transition configuration.
   * @default { type: "spring", damping: 25, stiffness: 300 }
   */
  transition?: Transition

  /**
   * Whether to loop through texts continuously.
   * @default true
   */
  loop?: boolean

  /**
   * Whether to auto-rotate texts.
   * @default true
   */
  auto?: boolean

  /**
   * How to split the text for animation.
   * @default "characters"
   */
  splitBy?: "words" | "characters" | "lines" | string

  /**
   * Callback function triggered when rotating to next text.
   * @default undefined
   */
  onNext?: (index: number) => void

  /**
   * Class name for the main container element.
   * @default undefined
   */
  mainClassName?: string

  /**
   * Class name for the split level wrapper elements.
   * @default undefined
   */
  splitLevelClassName?: string

  /**
   * Class name for individual animated elements.
   * @default undefined
   */
  elementLevelClassName?: string
}

/**
 * Interface for the ref object exposed by TextRotate component.
 * Provides methods to control text rotation programmatically.
 * This allows external components to trigger text changes
 * without relying on the automatic rotation.
 */
export interface TextRotateRef {
  /**
   * Advance to next text in sequence.
   * If at the end, will loop to beginning if loop prop is true.
   */
  next: () => void

  /**
   * Go back to previous text in sequence.
   * If at the start, will loop to end if loop prop is true.
   */
  previous: () => void

  /**
   * Jump to specific text by index.
   * Will clamp index between 0 and texts.length - 1.
   */
  jumpTo: (index: number) => void

  /**
   * Reset back to first text.
   * Equivalent to jumpTo(0).
   */
  reset: () => void
}

/**
 * Internal interface for representing words when splitting text by characters.
 * Used to maintain proper word spacing and line breaks while allowing
 * character-by-character animation. This prevents words from breaking
 * across lines during animation.
 */
interface WordObject {
  /**
   * Array of individual characters in the word.
   * Uses Intl.Segmenter when available for proper Unicode handling.
   */
  characters: string[]

  /**
   * Whether this word needs a space after it.
   * True for all words except the last one in a sequence.
   */
  needsSpace: boolean
}

const TextRotate = forwardRef<TextRotateRef, TextRotateProps>(
  (
    {
      texts,
      as = "p",
      transition = { type: "spring", damping: 25, stiffness: 300 },
      initial = { y: "100%", opacity: 0 },
      animate = { y: 0, opacity: 1 },
      exit = { y: "-120%", opacity: 0 },
      animatePresenceMode = "wait",
      animatePresenceInitial = false,
      rotationInterval = 2000,
      staggerDuration = 0,
      staggerFrom = "first",
      loop = true,
      auto = true,
      splitBy = "characters",
      onNext,
      mainClassName,
      splitLevelClassName,
      elementLevelClassName,
      ...props
    },
    ref
  ) => {
    const [currentTextIndex, setCurrentTextIndex] = useState(0)

    // Splitting the text into animation segments
    const elements = useMemo(() => {
      const currentText = texts[currentTextIndex]
      if (splitBy === "characters") {
        const text = currentText.split(" ")
        return text.map((word, i) => ({
          characters: splitIntoCharacters(word),
          needsSpace: i !== text.length - 1,
        }))
      }
      return splitBy === "words"
        ? currentText.split(" ")
        : splitBy === "lines"
          ? currentText.split("\n")
          : currentText.split(splitBy)
    }, [texts, currentTextIndex, splitBy])

    // Helper function to calculate stagger delay for each text segment
    const getStaggerDelay = useCallback(
      (index: number, totalChars: number) => {
        const total = totalChars
        if (staggerFrom === "first") return index * staggerDuration
        if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
        if (staggerFrom === "center") {
          const center = Math.floor(total / 2)
          return Math.abs(center - index) * staggerDuration
        }
        if (staggerFrom === "random") {
          const randomIndex = Math.floor(Math.random() * total)
          return Math.abs(randomIndex - index) * staggerDuration
        }
        return Math.abs(staggerFrom - index) * staggerDuration
      },
      [staggerFrom, staggerDuration]
    )

    // Helper function to handle index changes and trigger callback
    const handleIndexChange = useCallback(
      (newIndex: number) => {
        setCurrentTextIndex(newIndex)
        onNext?.(newIndex)
      },
      [onNext]
    )

    // Go to next text
    const next = useCallback(() => {
      const nextIndex =
        currentTextIndex === texts.length - 1
          ? loop
            ? 0
            : currentTextIndex
          : currentTextIndex + 1

      if (nextIndex !== currentTextIndex) {
        handleIndexChange(nextIndex)
      }
    }, [currentTextIndex, texts.length, loop, handleIndexChange])

    // Go back to previous text
    const previous = useCallback(() => {
      const prevIndex =
        currentTextIndex === 0
          ? loop
            ? texts.length - 1
            : currentTextIndex
          : currentTextIndex - 1

      if (prevIndex !== currentTextIndex) {
        handleIndexChange(prevIndex)
      }
    }, [currentTextIndex, texts.length, loop, handleIndexChange])

    // Jump to specific text by index
    const jumpTo = useCallback(
      (index: number) => {
        const validIndex = Math.max(0, Math.min(index, texts.length - 1))
        if (validIndex !== currentTextIndex) {
          handleIndexChange(validIndex)
        }
      },
      [texts.length, currentTextIndex, handleIndexChange]
    )

    // Reset back to first text
    const reset = useCallback(() => {
      if (currentTextIndex !== 0) {
        handleIndexChange(0)
      }
    }, [currentTextIndex, handleIndexChange])

    // Get animation props for each text segment. If array is provided, states will be mapped to text segments cyclically.
    const getAnimationProps = useCallback(
      (index: number) => {
        const getProp = (
          prop:
            | MotionProps["initial"]
            | MotionProps["initial"][]
            | MotionProps["animate"]
            | MotionProps["animate"][]
            | MotionProps["exit"]
            | MotionProps["exit"][]
        ) => {
          if (Array.isArray(prop)) {
            return prop[index % prop.length]
          }
          return prop
        }

        return {
          initial: getProp(initial) as MotionProps["initial"],
          animate: getProp(animate) as MotionProps["animate"],
          exit: getProp(exit) as MotionProps["exit"],
        }
      },
      [initial, animate, exit]
    )

    // Expose all navigation functions via ref
    useImperativeHandle(
      ref,
      () => ({
        next,
        previous,
        jumpTo,
        reset,
      }),
      [next, previous, jumpTo, reset]
    )

    // Auto-rotate text
    useEffect(() => {
      if (!auto) return
      const intervalId = setInterval(next, rotationInterval)
      return () => clearInterval(intervalId)
    }, [next, rotationInterval, auto])

    // Custom motion component to render the text as a custom HTML tag provided via prop
    const MotionComponent = useMemo(() => motion.create(as ?? "p"), [as])

    return (
      <MotionComponent
        className={cn("flex flex-wrap whitespace-pre-wrap", mainClassName)}
        transition={transition}
        layout
        {...props}
      >
        <span className="sr-only">{texts[currentTextIndex]}</span>

        <AnimatePresence
          mode={animatePresenceMode}
          initial={animatePresenceInitial}
        >
          <motion.span
            key={currentTextIndex}
            className={cn(
              "flex flex-wrap",
              splitBy === "lines" && "flex-col w-full"
            )}
            aria-hidden
            layout
          >
            {(splitBy === "characters"
              ? (elements as WordObject[])
              : (elements as string[]).map((el, i) => ({
                  characters: [el],
                  needsSpace: i !== elements.length - 1,
                }))
            ).map((wordObj, wordIndex, array) => {
              const previousCharsCount = array
                .slice(0, wordIndex)
                .reduce((sum, word) => sum + word.characters.length, 0)

              return (
                <span
                  key={wordIndex}
                  className={cn("inline-flex", splitLevelClassName)}
                >
                  {wordObj.characters.map((char, charIndex) => {
                    const totalIndex = previousCharsCount + charIndex
                    const animationProps = getAnimationProps(totalIndex)
                    return (
                      <span 
                      key={totalIndex}
                      className={cn(elementLevelClassName)}
                      >
                        <motion.span
                          {...animationProps}
                          key={charIndex}
                          transition={{
                            ...transition,
                            delay: getStaggerDelay(
                              previousCharsCount + charIndex,
                              array.reduce(
                                (sum, word) => sum + word.characters.length,
                                0
                              )
                            ),
                          }}
                          className={"inline-block"}
                        >
                          {char}
                        </motion.span>
                      </span>
                    )
                  })}
                  {wordObj.needsSpace && (
                    <span className="whitespace-pre"> </span>
                  )}
                </span>
              )
            })}
          </motion.span>
        </AnimatePresence>
      </MotionComponent>
    )
  }
)

TextRotate.displayName = "TextRotate"

export default TextRotate
src/fancy/components/text/typewriter.tsx
파일 저장

"use client"

import { ElementType, useEffect, useState } from "react"
import { motion, Variants } from "motion/react"

import { cn } from "@/lib/utils"

interface TypewriterProps {
  /**
   * Text or array of texts to type out
   */
  text: string | string[]

  /**
   * HTML Tag to render the component as
   * @default div
   */
  as?: ElementType

  /**
   * Speed of typing in milliseconds
   * @default 50
   */
  speed?: number

  /**
   * Initial delay before typing starts
   * @default 0
   */
  initialDelay?: number

  /**
   * Time to wait between typing and deleting
   * @default 2000
   */
  waitTime?: number

  /**
   * Speed of deleting characters
   * @default 30
   */
  deleteSpeed?: number

  /**
   * Whether to loop through texts array
   * @default true
   */
  loop?: boolean

  /**
   * Optional class name for styling
   */
  className?: string

  /**
   * Whether to show the cursor
   * @default true
   */
  showCursor?: boolean

  /**
   * Hide cursor while typing
   * @default false
   */
  hideCursorOnType?: boolean

  /**
   * Character or React node to use as cursor
   * @default "|"
   */
  cursorChar?: string | React.ReactNode

  /**
   * Animation variants for cursor
   */
  cursorAnimationVariants?: {
    initial: Variants["initial"]
    animate: Variants["animate"]
  }

  /**
   * Optional class name for cursor styling
   */
  cursorClassName?: string
}

const Typewriter = ({
  text,
  as: Tag = "div",
  speed = 50,
  initialDelay = 0,
  waitTime = 2000,
  deleteSpeed = 30,
  loop = true,
  className,
  showCursor = true,
  hideCursorOnType = false,
  cursorChar = "|",
  cursorClassName = "ml-1",
  cursorAnimationVariants = {
    initial: { opacity: 0 },
    animate: {
      opacity: 1,
      transition: {
        duration: 0.01,
        repeat: Infinity,
        repeatDelay: 0.4,
        repeatType: "reverse",
      },
    },
  },
  ...props
}: TypewriterProps & React.HTMLAttributes<HTMLElement>) => {
  const [displayText, setDisplayText] = useState("")
  const [currentIndex, setCurrentIndex] = useState(0)
  const [isDeleting, setIsDeleting] = useState(false)
  const [currentTextIndex, setCurrentTextIndex] = useState(0)

  const texts = Array.isArray(text) ? text : [text]

  useEffect(() => {
    let timeout: NodeJS.Timeout

    const currentText = texts[currentTextIndex]

    const startTyping = () => {
      if (isDeleting) {
        if (displayText === "") {
          setIsDeleting(false)
          if (currentTextIndex === texts.length - 1 && !loop) {
            return
          }
          setCurrentTextIndex((prev) => (prev + 1) % texts.length)
          setCurrentIndex(0)
          timeout = setTimeout(() => {}, waitTime)
        } else {
          timeout = setTimeout(() => {
            setDisplayText((prev) => prev.slice(0, -1))
          }, deleteSpeed)
        }
      } else {
        if (currentIndex < currentText.length) {
          timeout = setTimeout(() => {
            setDisplayText((prev) => prev + currentText[currentIndex])
            setCurrentIndex((prev) => prev + 1)
          }, speed)
        } else if (texts.length > 1) {
          timeout = setTimeout(() => {
            setIsDeleting(true)
          }, waitTime)
        }
      }
    }

    // Apply initial delay only at the start
    if (currentIndex === 0 && !isDeleting && displayText === "") {
      timeout = setTimeout(startTyping, initialDelay)
    } else {
      startTyping()
    }

    return () => clearTimeout(timeout)
  }, [
    currentIndex,
    displayText,
    isDeleting,
    speed,
    deleteSpeed,
    waitTime,
    texts,
    currentTextIndex,
    loop,
  ])

  return (
    <Tag className={cn("inline whitespace-pre-wrap tracking-tight", className)} {...props}>
      <span>{displayText}</span>
      {showCursor && (
        <motion.span
          variants={cursorAnimationVariants}
          className={cn(
            cursorClassName,
            hideCursorOnType &&
              (currentIndex < texts[currentTextIndex].length || isDeleting)
              ? "hidden"
              : ""
          )}
          initial="initial"
          animate="animate"
        >
          {cursorChar}
        </motion.span>
      )}
    </Tag>
  )
}

export default Typewriter
src/fancy/components/text/underline-center.tsx
파일 저장

"use client"

import { ElementType, useEffect, useRef, useMemo } from "react"
import { motion, ValueAnimationTransition } from "motion/react"
import { cn } from "@/lib/utils"

interface UnderlineProps {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   * @default span
   */
  as?: ElementType

  /**
   * Optional class name for styling
   */
  className?: string

  /**
   * Animation transition configuration
   * @default { duration: 0.25, ease: "easeInOut" }
   */
  transition?: ValueAnimationTransition

  /**
   * Height of the underline as a ratio of font size
   * @default 0.1
   */
  underlineHeightRatio?: number

  /**
   * Padding of the underline as a ratio of font size
   * @default 0.01
   */
  underlinePaddingRatio?: number
}

const CenterUnderline = ({
  children,
  as,
  className,
  transition = { duration: 0.25, ease: "easeInOut" },
  underlineHeightRatio = 0.1,
  underlinePaddingRatio = 0.01,
  ...props
}: UnderlineProps) => {
  const textRef = useRef<HTMLSpanElement>(null)
  const MotionComponent = useMemo(() => motion.create(as ?? "span"), [as])

  useEffect(() => {
    const updateUnderlineStyles = () => {
      if (textRef.current) {
        const fontSize = parseFloat(getComputedStyle(textRef.current).fontSize)
        const underlineHeight = fontSize * underlineHeightRatio
        const underlinePadding = fontSize * underlinePaddingRatio
        textRef.current.style.setProperty(
          "--underline-height",
          `${underlineHeight}px`
        )
        textRef.current.style.setProperty(
          "--underline-padding",
          `${underlinePadding}px`
        )
      }
    }

    updateUnderlineStyles()
    window.addEventListener("resize", updateUnderlineStyles)

    return () => window.removeEventListener("resize", updateUnderlineStyles)
  }, [underlineHeightRatio, underlinePaddingRatio])

  const underlineVariants = {
    hidden: {
      width: 0,
      originX: 0.5,
    },
    visible: {
      width: "100%",
      transition: transition,
    },
  }

  return (
    <MotionComponent
      className={cn("relative inline-block cursor-pointer", className)}
      whileHover="visible"
      ref={textRef}
      {...props}
    >
      <span>{children}</span>
      <motion.div
        className="absolute left-1/2 bg-current -translate-x-1/2"
        style={{
          height: "var(--underline-height)",
          bottom: "calc(-1 * var(--underline-padding))",
        }}
        variants={underlineVariants}
        aria-hidden="true"
      />
    </MotionComponent>
  )
}

CenterUnderline.displayName = "CenterUnderline"

export default CenterUnderline
src/fancy/components/text/underline-comes-in-goes-out.tsx
파일 저장

"use client"

import { ElementType, useEffect, useRef, useState, useMemo } from "react"
import cn from "clsx"
import {
  motion,
  useAnimationControls,
  ValueAnimationTransition,
} from "motion/react"

interface ComesInGoesOutUnderlineProps {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   * @default span
   */
  as?: ElementType

  /**
   * Direction of the animation
   * @default "left"
   */
  direction?: "left" | "right"

  /**
   * Optional class name for styling
   */
  className?: string

  /**
   * Height of the underline as a ratio of font size
   * @default 0.1
   */
  underlineHeightRatio?: number

  /**
   * Padding of the underline as a ratio of font size
   * @default 0.01
   */
  underlinePaddingRatio?: number

  /**
   * Animation transition configuration
   * @default { duration: 0.4, ease: "easeInOut" }
   */
  transition?: ValueAnimationTransition
}

const ComesInGoesOutUnderline = ({
  children,
  as,
  direction = "left",
  className,
  underlineHeightRatio = 0.1,
  underlinePaddingRatio = 0.01,
  transition = {
    duration: 0.4,
    ease: "easeInOut",
  },
  ...props
}: ComesInGoesOutUnderlineProps) => {
  const controls = useAnimationControls()
  const [blocked, setBlocked] = useState(false)
  const textRef = useRef<HTMLSpanElement>(null)
  const MotionComponent = useMemo(() => motion.create(as ?? "span"), [as])

  useEffect(() => {
    const updateUnderlineStyles = () => {
      if (textRef.current) {
        const fontSize = parseFloat(getComputedStyle(textRef.current).fontSize)
        const underlineHeight = fontSize * underlineHeightRatio
        const underlinePadding = fontSize * underlinePaddingRatio
        textRef.current.style.setProperty(
          "--underline-height",
          `${underlineHeight}px`
        )
        textRef.current.style.setProperty(
          "--underline-padding",
          `${underlinePadding}px`
        )
      }
    }

    updateUnderlineStyles()
    window.addEventListener("resize", updateUnderlineStyles)

    return () => window.removeEventListener("resize", updateUnderlineStyles)
  }, [underlineHeightRatio, underlinePaddingRatio])

  const animate = async () => {
    if (blocked) return

    setBlocked(true)

    await controls.start({
      width: "100%",
      transition,
      transitionEnd: {
        left: direction === "left" ? "auto" : 0,
        right: direction === "left" ? 0 : "auto",
      },
    })

    await controls.start({
      width: 0,
      transition,
      transitionEnd: {
        left: direction === "left" ? 0 : "",
        right: direction === "left" ? "" : 0,
      },
    })

    setBlocked(false)
  }

  return (
    <MotionComponent
      className={cn("relative inline-block cursor-pointer", className)}
      onHoverStart={animate}
      ref={textRef}
      {...props}
    >
      <span>{children}</span>
      <motion.span
        className={cn("absolute bg-current w-0", {
          "left-0": direction === "left",
          "right-0": direction === "right",
        })}
        style={{
          height: "var(--underline-height)",
          bottom: "calc(1 * var(--underline-padding))",
        }}
        animate={controls}
        aria-hidden="true"
      />
    </MotionComponent>
  )
}

ComesInGoesOutUnderline.displayName = "ComesInGoesOutUnderline"

export default ComesInGoesOutUnderline
src/fancy/components/text/underline-goes-out-comes-in.tsx
파일 저장

"use client"

import { ElementType, useEffect, useRef, useState, useMemo } from "react"
import cn from "clsx"
import {
  motion,
  useAnimationControls,
  ValueAnimationTransition,
} from "motion/react"

interface GoesOutComesInUnderlineProps {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   * @default span
   */
  as?: ElementType

  /**
   * Direction of the animation
   * @default "left"
   */
  direction?: "left" | "right"

  /**
   * Optional class name for styling
   */
  className?: string

  /**
   * Height of the underline as a ratio of font size
   * @default 0.1
   */
  underlineHeightRatio?: number

  /**
   * Padding of the underline as a ratio of font size
   * @default 0.01
   */
  underlinePaddingRatio?: number

  /**
   * Animation transition configuration
   * @default { duration: 0.5, ease: "easeOut" }
   */
  transition?: ValueAnimationTransition
}

const GoesOutComesInUnderline = ({
  children,
  as,
  direction = "left",
  className,
  underlineHeightRatio = 0.1,
  underlinePaddingRatio = 0.01,
  transition = {
    duration: 0.5,
    ease: "easeOut",
  },
  ...props
}: GoesOutComesInUnderlineProps) => {
  const controls = useAnimationControls()
  const [blocked, setBlocked] = useState(false)
  const textRef = useRef<HTMLSpanElement>(null)
  const MotionComponent = useMemo(() => motion.create(as ?? "span"), [as])

  useEffect(() => {
    const updateUnderlineStyles = () => {
      if (textRef.current) {
        const fontSize = parseFloat(getComputedStyle(textRef.current).fontSize)
        const underlineHeight = fontSize * underlineHeightRatio
        const underlinePadding = fontSize * underlinePaddingRatio
        textRef.current.style.setProperty(
          "--underline-height",
          `${underlineHeight}px`
        )
        textRef.current.style.setProperty(
          "--underline-padding",
          `${underlinePadding}px`
        )
      }
    }

    updateUnderlineStyles()
    window.addEventListener("resize", updateUnderlineStyles)

    return () => window.removeEventListener("resize", updateUnderlineStyles)
  }, [underlineHeightRatio, underlinePaddingRatio])

  const animate = async () => {
    if (blocked) return

    setBlocked(true)

    await controls.start({
      width: 0,
      transition,
      transitionEnd: {
        left: direction === "left" ? "auto" : 0,
        right: direction === "left" ? 0 : "auto",
      },
    })

    await controls.start({
      width: "100%",
      transition,
      transitionEnd: {
        left: direction === "left" ? 0 : "",
        right: direction === "left" ? "" : 0,
      },
    })

    setBlocked(false)
  }

  return (
    <MotionComponent
      className={cn("relative inline-block cursor-pointer", className)}
      onHoverStart={animate}
      ref={textRef}
      {...props}
    >
      <span>{children}</span>
      <motion.span
        className={cn("absolute bg-current", {
          "left-0": direction === "left",
          "right-0": direction === "right",
        })}
        style={{
          height: "var(--underline-height)",
          bottom: "calc(-1 * var(--underline-padding))",
          width: "100%",
        }}
        animate={controls}
        aria-hidden="true"
      />
    </MotionComponent>
  )
}

GoesOutComesInUnderline.displayName = "GoesOutComesInUnderline"

export default GoesOutComesInUnderline
src/fancy/components/text/underline-to-background.tsx
파일 저장

"use client"

import { ElementType, useEffect, useMemo, useRef } from "react"
import { motion, ValueAnimationTransition } from "motion/react"

import { cn } from "@/lib/utils"

interface UnderlineProps {
  /**
   * The content to be displayed and animated
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as
   * @default span
   */
  as?: ElementType

  /**
   * Optional class name for styling
   */
  className?: string

  /**
   * Animation transition configuration
   * @default { type: "spring", damping: 30, stiffness: 300 }
   */
  transition?: ValueAnimationTransition

  /**
   * The color that the text will animate to on hover
   */
  targetTextColor: string

  /**
   * Height of the underline as a ratio of font size
   * @default 0.1
   */
  underlineHeightRatio?: number

  /**
   * Padding of the underline as a ratio of font size
   * @default 0.01
   */
  underlinePaddingRatio?: number
}

const UnderlineToBackground = ({
  children,
  as,
  className,
  transition = { type: "spring", damping: 30, stiffness: 300 },
  underlineHeightRatio = 0.1, // Default to 10% of font size
  underlinePaddingRatio = 0.01, // Default to 1% of font size
  targetTextColor = "#fef",
  ...props
}: UnderlineProps) => {
  const textRef = useRef<HTMLSpanElement>(null)

  // Create custom motion component based on the 'as' prop
  const MotionComponent = useMemo(() => motion.create(as ?? "span"), [as])

  // Update CSS custom properties based on font size
  useEffect(() => {
    const updateUnderlineStyles = () => {
      if (textRef.current) {
        const fontSize = parseFloat(getComputedStyle(textRef.current).fontSize)
        const underlineHeight = fontSize * underlineHeightRatio
        const underlinePadding = fontSize * underlinePaddingRatio
        textRef.current.style.setProperty(
          "--underline-height",
          `${underlineHeight}px`
        )
        textRef.current.style.setProperty(
          "--underline-padding",
          `${underlinePadding}px`
        )
      }
    }

    updateUnderlineStyles()
    window.addEventListener("resize", updateUnderlineStyles)

    return () => window.removeEventListener("resize", updateUnderlineStyles)
  }, [underlineHeightRatio, underlinePaddingRatio])

  // Animation variants for the underline background
  const underlineVariants = {
    initial: {
      height: "var(--underline-height)",
    },
    target: {
      height: "100%",
      transition: transition,
    },
  }

  // Animation variants for the text color
  const textVariants = {
    initial: {
      color: "currentColor",
    },
    target: {
      color: targetTextColor,
      transition: transition,
    },
  }

  return (
    <MotionComponent
      className={cn("relative inline-block cursor-pointer", className)}
      whileHover="target"
      ref={textRef}
      {...props}
    >
      <motion.div
        className="absolute bg-current w-full"
        style={{
          height: "var(--underline-height)",
          bottom: "calc(-1 * var(--underline-padding))",
        }}
        variants={underlineVariants}
        aria-hidden="true"
      />
      <motion.span variants={textVariants} className="text-current relative">
        {children}
      </motion.span>
    </MotionComponent>
  )
}

UnderlineToBackground.displayName = "UnderlineToBackground"

export default UnderlineToBackground
src/fancy/components/text/variable-font-and-cursor.tsx
파일 저장

"use client"

import React, { ElementType, useCallback, useRef } from "react"
import { motion, useAnimationFrame } from "motion/react"

import { cn } from "@/lib/utils"
import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"

/**
 * Interface for defining a single font variation axis.
 * Each axis represents a dimension of variation in a variable font. You should check the font variation settings of the font you are using to see the available axes.
 */
interface FontVariationAxis {
  /**
   * The name of the font variation axis (e.g., "wght" for weight, "slnt" for slant).
   * This corresponds to the OpenType variation axis tags, but can be arbitrary. Make sure to check the font variation settings of the font you are using to see the available axes.
   */
  name: string

  /**
   * The minimum value for this axis.
   * Applied when the cursor is at the left edge (for x-axis) or top edge (for y-axis).
   */
  min: number

  /**
   * The maximum value for this axis.
   * Applied when the cursor is at the right edge (for x-axis) or bottom edge (for y-axis).
   */
  max: number
}

/**
 * Interface for mapping cursor position to font variation settings.
 * Allows independent control of two font variation axes based on cursor movement.
 */
interface FontVariationMapping {
  /**
   * Font variation axis controlled by horizontal cursor movement.
   */
  x: FontVariationAxis

  /**
   * Font variation axis controlled by vertical cursor movement.
   */
  y: FontVariationAxis
}

/**
 * Props for the VariableFontAndCursor component.
 */
interface TextProps extends React.HTMLAttributes<HTMLElement> {
  /**
   * The text content to display and animate.
   * Required prop with no default value.
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as.
   * @default "span"
   */
  as?: ElementType

  /**
   * Mapping configuration that defines how cursor position affects font variation settings.
   * Maps x and y cursor positions to specific font variation axes and value ranges.
   * Required prop with no default value.
   */
  fontVariationMapping: FontVariationMapping

  /**
   * Reference to the container element for mouse tracking.
   * The cursor position will be calculated relative to this container's bounds.
   * Required prop with no default value.
   */
  containerRef: React.RefObject<HTMLDivElement | null>
}

const VariableFontAndCursor = ({
  children,
  as = "span",
  fontVariationMapping,
  className,
  containerRef,
  ...props
}: TextProps) => {
  // Hook to track mouse position relative to the specified container
  const mousePositionRef = useMousePositionRef(containerRef)

  // Ref for the visible text span to apply font variation settings
  const spanRef = useRef<HTMLSpanElement>(null)

  /**
   * Calculates font variation settings based on cursor position within the container.
   *
   * This function maps the cursor's x and y coordinates to font variation values
   * by interpolating between the minimum and maximum values defined in the mapping.
   * The position is normalized to a 0-1 range based on the container dimensions.
   *
   * @param xPosition - Horizontal cursor position relative to container
   * @param yPosition - Vertical cursor position relative to container
   * @returns CSS font-variation-settings string with calculated values
   */
  const interpolateFontVariationSettings = useCallback(
    (xPosition: number, yPosition: number) => {
      const container = containerRef.current
      if (!container) return "0 0" // Return default values if container is null

      // Get container dimensions for normalization
      const containerWidth = container.clientWidth
      const containerHeight = container.clientHeight

      // Normalize cursor position to 0-1 range, clamped to container bounds
      const xProgress = Math.min(Math.max(xPosition / containerWidth, 0), 1)
      const yProgress = Math.min(Math.max(yPosition / containerHeight, 0), 1)

      // Interpolate between min and max values for each axis
      const xValue =
        fontVariationMapping.x.min +
        (fontVariationMapping.x.max - fontVariationMapping.x.min) * xProgress
      const yValue =
        fontVariationMapping.y.min +
        (fontVariationMapping.y.max - fontVariationMapping.y.min) * yProgress

      // Return CSS font-variation-settings string
      return `'${fontVariationMapping.x.name}' ${xValue}, '${fontVariationMapping.y.name}' ${yValue}`
    },
    [fontVariationMapping, containerRef]
  )

  // Use animation frame to smoothly update font variations on every frame
  // This ensures smooth transitions as the cursor moves
  useAnimationFrame(() => {
    const settings = interpolateFontVariationSettings(
      mousePositionRef.current.x,
      mousePositionRef.current.y
    )
    if (spanRef.current) {
      spanRef.current.style.fontVariationSettings = settings
    }
  })

  // Custom motion component to render as the specified HTML tag
  const MotionComponent = motion.create(as)

  return (
    <MotionComponent
      className={cn(className)}
      data-text={children}
      ref={spanRef}
      {...props}
    >
      <span className="inline-block">{children}</span>
    </MotionComponent>
  )
}

export default VariableFontAndCursor
src/fancy/components/text/variable-font-cursor-proximity.tsx
파일 저장

"use client"

import React, { ElementType, forwardRef, useMemo, useRef } from "react"
import { motion, useAnimationFrame } from "motion/react"

import { cn } from "@/lib/utils"
import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"

/**
 * Props for the VariableFontCursorProximity component.
 */
interface TextProps extends React.HTMLAttributes<HTMLElement> {
  /**
   * The text content to display and animate.
   * Each letter will respond individually to cursor proximity.
   * Required prop with no default value.
   */
  children: React.ReactNode

  /**
   * HTML Tag to render the component as.
   * @default "span"
   */
  as?: ElementType

  /**
   * Default font variation settings applied when cursor is outside the radius.
   * Should be a CSS font-variation-settings string (e.g., "'wght' 400, 'slnt' 0").
   * You should check the font variation settings of the font you are using to see the available axes.
   * Required prop with no default value.
   */
  fromFontVariationSettings: string

  /**
   * Target font variation settings applied when cursor is at the center of a letter.
   * Should be a CSS font-variation-settings string (e.g., "'wght' 900, 'slnt' 15").
   * Make sure to check the font variation settings of the font you are using to see the available axes.
   * Required prop with no default value.
   */
  toFontVariationSettings: string

  /**
   * Reference to the container element for mouse tracking.
   * The cursor position will be calculated relative to this container's bounds.
   * Required prop with no default value.
   */
  containerRef: React.RefObject<HTMLDivElement | null>

  /**
   * The radius in pixels within which letters respond to cursor proximity.
   * Letters outside this radius will use the default font variation settings.
   * @default 50
   */
  radius?: number

  /**
   * The falloff function that determines how the effect diminishes with distance.
   * - "linear": Linear interpolation (straight line falloff)
   * - "exponential": Quadratic falloff (more dramatic near cursor)
   * - "gaussian": Bell curve falloff (smooth, natural feeling)
   * @default "linear"
   */
  falloff?: "linear" | "exponential" | "gaussian"
}

const VariableFontCursorProximity = forwardRef<HTMLElement, TextProps>(
  (
    {
      children,
      as = "span",
      fromFontVariationSettings,
      toFontVariationSettings,
      containerRef,
      radius = 50,
      falloff = "linear",
      className,
      ...props
    },
    ref
  ) => {
    // Refs to store references to each individual letter element
    const letterRefs = useRef<(HTMLSpanElement | null)[]>([])

    // Cache for interpolated font settings to avoid recalculation
    const interpolatedSettingsRef = useRef<string[]>([])

    // Hook to track mouse position relative to the specified container
    const mousePositionRef = useMousePositionRef(containerRef)

    /**
     * Parse and prepare font variation settings for interpolation.
     *
     * Converts CSS font-variation-settings strings into structured data that can be
     * efficiently interpolated. Each axis is parsed with its from/to values for
     * smooth transitions during proximity-based animation.
     *
     * Expected format: "'wght' 400, 'slnt' 0" -> Map of axis names to values
     */
    const parsedSettings = useMemo(() => {
      // Parse the 'from' font variation settings string
      const fromSettings = new Map(
        fromFontVariationSettings
          .split(",")
          .map((s) => s.trim())
          .map((s) => {
            const [name, value] = s.split(" ")
            return [name.replace(/['"]/g, ""), parseFloat(value)]
          })
      )

      // Parse the 'to' font variation settings string
      const toSettings = new Map(
        toFontVariationSettings
          .split(",")
          .map((s) => s.trim())
          .map((s) => {
            const [name, value] = s.split(" ")
            return [name.replace(/['"]/g, ""), parseFloat(value)]
          })
      )

      // Create structured data for each axis with from/to values
      return Array.from(fromSettings.entries()).map(([axis, fromValue]) => ({
        axis,
        fromValue,
        toValue: toSettings.get(axis) ?? fromValue,
      }))
    }, [fromFontVariationSettings, toFontVariationSettings])

    /**
     * Calculate Euclidean distance between two points.
     *
     * Used to determine the distance between the cursor position and each letter's center.
     * This distance is then used to calculate the proximity effect strength.
     *
     * @param x1 - X coordinate of first point (cursor)
     * @param y1 - Y coordinate of first point (cursor)
     * @param x2 - X coordinate of second point (letter center)
     * @param y2 - Y coordinate of second point (letter center)
     * @returns Distance in pixels between the two points
     */
    const calculateDistance = (
      x1: number,
      y1: number,
      x2: number,
      y2: number
    ): number => {
      return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
    }

    /**
     * Calculate the falloff value based on distance and selected falloff type.
     *
     * This function determines how strongly the proximity effect affects each letter
     * based on its distance from the cursor. Different falloff types create different
     * visual effects and feelings of interaction.
     *
     * @param distance - Distance in pixels from cursor to letter center
     * @returns Falloff value between 0 (no effect) and 1 (full effect)
     */
    const calculateFalloff = (distance: number): number => {
      // Normalize distance to 0-1 range within the radius
      const normalizedDistance = Math.min(Math.max(1 - distance / radius, 0), 1)

      switch (falloff) {
        case "exponential":
          // Quadratic falloff - more dramatic effect near cursor
          return Math.pow(normalizedDistance, 2)
        case "gaussian":
          // Bell curve falloff - smooth, natural feeling
          return Math.exp(-Math.pow(distance / (radius / 2), 2) / 2)
        case "linear":
        default:
          // Linear falloff - consistent rate of change
          return normalizedDistance
      }
    }

    // Use animation frame to smoothly update font variations for all letters
    // This ensures smooth transitions as the cursor moves across the text
    useAnimationFrame(() => {
      if (!containerRef.current) return
      const containerRect = containerRef.current.getBoundingClientRect()

      // Process each letter individually for proximity-based animation
      letterRefs.current.forEach((letterRef, index) => {
        if (!letterRef) return

        // Calculate letter's center position relative to container
        const rect = letterRef.getBoundingClientRect()
        const letterCenterX = rect.left + rect.width / 2 - containerRect.left
        const letterCenterY = rect.top + rect.height / 2 - containerRect.top

        // Calculate distance from cursor to this letter's center
        const distance = calculateDistance(
          mousePositionRef.current.x,
          mousePositionRef.current.y,
          letterCenterX,
          letterCenterY
        )

        // If letter is outside the effect radius, reset to default settings
        if (distance >= radius) {
          if (
            letterRef.style.fontVariationSettings !== fromFontVariationSettings
          ) {
            letterRef.style.fontVariationSettings = fromFontVariationSettings
          }
          return
        }

        // Calculate falloff strength based on distance and falloff type
        const falloffValue = calculateFalloff(distance)

        // Interpolate between from and to settings for each axis
        const newSettings = parsedSettings
          .map(({ axis, fromValue, toValue }) => {
            const interpolatedValue =
              fromValue + (toValue - fromValue) * falloffValue
            return `'${axis}' ${interpolatedValue}`
          })
          .join(", ")

        // Cache and apply the interpolated settings
        interpolatedSettingsRef.current[index] = newSettings
        letterRef.style.fontVariationSettings = newSettings
      })
    })

    // Split text into words and track letter indices across all words
    const words = String(children).split(" ")
    let letterIndex = 0
    const ElementTag = as

    return (
      <ElementTag
        ref={ref}
        className={cn(
          className,
        )}
        {...props}
        data-text={children}
      >
        {words.map((word, wordIndex) => (
          <span
            key={wordIndex}
            className="inline-block whitespace-nowrap"
            aria-hidden
          >
            {word.split("").map((letter) => {
              const currentLetterIndex = letterIndex++
              return (
                <motion.span
                  key={currentLetterIndex}
                  ref={(el: HTMLSpanElement | null) => {
                    letterRefs.current[currentLetterIndex] = el
                  }}
                  className="inline-block"
                  aria-hidden="true"
                  style={{
                    fontVariationSettings:
                      interpolatedSettingsRef.current[currentLetterIndex],
                  }}
                >
                  {letter}
                </motion.span>
              )
            })}
            {wordIndex < words.length - 1 && (
              <span className="inline-block">&nbsp;</span>
            )}
          </span>
        ))}
        <span className="sr-only">{children}</span>
      </ElementTag>
    )
  }
)

VariableFontCursorProximity.displayName = "VariableFontCursorProximity"
export default VariableFontCursorProximity
src/fancy/components/text/variable-font-hover-by-letter.tsx
파일 저장

"use client"

import { useState } from "react"
import { debounce } from "lodash"
import { AnimationOptions, motion, stagger, useAnimate } from "motion/react"

interface TextProps {
  label: string
  fromFontVariationSettings: string
  toFontVariationSettings: string
  transition?: AnimationOptions
  staggerDuration?: number
  staggerFrom?: "first" | "last" | "center" | number
  className?: string
  onClick?: () => void
}

const VariableFontHoverByLetter = ({
  label,
  fromFontVariationSettings = "'wght' 400, 'slnt' 0",
  toFontVariationSettings = "'wght' 900, 'slnt' -10",
  transition = {
    type: "spring",
    duration: 0.7,
  },
  staggerDuration = 0.03,
  staggerFrom = "first",
  className,
  onClick,
  ...props
}: TextProps) => {
  const [scope, animate] = useAnimate()
  const [isHovered, setIsHovered] = useState(false)

  const mergeTransition = (baseTransition: AnimationOptions) => ({
    ...baseTransition,
    delay: stagger(staggerDuration, {
      from: staggerFrom,
    }),
  })

  const hoverStart = debounce(
    () => {
      if (isHovered) return
      setIsHovered(true)

      animate(
        ".letter",
        { fontVariationSettings: toFontVariationSettings },
        mergeTransition(transition)
      )
    },
    100,
    { leading: true, trailing: true }
  )

  const hoverEnd = debounce(
    () => {
      setIsHovered(false)

      animate(
        ".letter",
        { fontVariationSettings: fromFontVariationSettings },
        mergeTransition(transition)
      )
    },
    100,
    { leading: true, trailing: true }
  )

  return (
    <motion.span
      className={`${className}`}
      onHoverStart={hoverStart}
      onHoverEnd={hoverEnd}
      onClick={onClick}
      ref={scope}
      {...props}
    >
      <span className="sr-only">{label}</span>

      {label.split("").map((letter: string, i: number) => {
        return (
          <motion.span
            key={i}
            className="inline-block whitespace-pre letter"
            aria-hidden="true"
          >
            {letter}
          </motion.span>
        )
      })}
    </motion.span>
  )
}

export default VariableFontHoverByLetter
src/fancy/components/text/variable-font-hover-by-random-letter.tsx
파일 저장

"use client"

import { useMemo } from "react"
import { motion, Transition } from "motion/react"

// Function to shuffle an array
function shuffleArray(array: number[]) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[array[i], array[j]] = [array[j], array[i]]
  }
}

interface TextProps {
  label: string
  fromFontVariationSettings: string
  toFontVariationSettings: string
  transition?: Transition
  staggerDuration?: number
  className?: string
  onClick?: () => void
}

const VariableFontHoverByRandomLetter = ({
  label,
  fromFontVariationSettings = "'wght' 400, 'slnt' 0",
  toFontVariationSettings = "'wght' 900, 'slnt' -10",
  transition = {
    type: "spring",
    duration: 0.7,
  },
  staggerDuration = 0.03,
  className,
  onClick,
  ...props
}: TextProps) => {
  const shuffledIndices = useMemo(() => {
    const indices = Array.from({ length: label.length }, (_, i) => i)
    shuffleArray(indices)
    return indices
  }, [label])

  const letterVariants = {
    hover: (index: number) => ({
      fontVariationSettings: toFontVariationSettings,
      transition: {
        ...transition,
        delay: staggerDuration * index,
      },
    }),
    initial: (index: number) => ({
      fontVariationSettings: fromFontVariationSettings,
      transition: {
        ...transition,
        delay: staggerDuration * index,
      },
    }),
  }

  return (
    <motion.span
      className={`${className}`}
      onClick={onClick}
      whileHover="hover"
      initial="initial"
      {...props}
    >
      <span className="sr-only">{label}</span>

      {label.split("").map((letter: string, i: number) => {
        const index = shuffledIndices[i]
        return (
          <motion.span
            key={i}
            className="inline-block whitespace-pre"
            aria-hidden="true"
            variants={letterVariants}
            custom={index}
          >
            {letter}
          </motion.span>
        )
      })}
    </motion.span>
  )
}

export default VariableFontHoverByRandomLetter
src/fancy/components/text/vertical-cut-reveal.tsx
파일 저장

"use client"

import { AnimationOptions, motion } from "motion/react"
import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from "react"

import { cn } from "@/lib/utils"

interface TextProps {
  children: React.ReactNode
  reverse?: boolean
  transition?: AnimationOptions
  splitBy?: "words" | "characters" | "lines" | string
  staggerDuration?: number
  staggerFrom?: "first" | "last" | "center" | "random" | number
  containerClassName?: string
  wordLevelClassName?: string
  elementLevelClassName?: string
  onClick?: () => void
  onStart?: () => void
  onComplete?: () => void
  autoStart?: boolean // Whether to start the animation automatically
}

// Ref interface to allow external control of the animation
export interface VerticalCutRevealRef {
  startAnimation: () => void
  reset: () => void
}

interface WordObject {
  characters: string[]
  needsSpace: boolean
}

const VerticalCutReveal = forwardRef<VerticalCutRevealRef, TextProps>(
  (
    {
      children,
      reverse = false,
      transition = {
        type: "spring",
        stiffness: 190,
        damping: 22,
      },
      splitBy = "words",
      staggerDuration = 0.2,
      staggerFrom = "first",
      containerClassName,
      wordLevelClassName,
      elementLevelClassName,
      onClick,
      onStart,
      onComplete,
      autoStart = true,
      ...props
    },
    ref
  ) => {
    const containerRef = useRef<HTMLSpanElement>(null)
    const text =
      typeof children === "string" ? children : children?.toString() || ""
    const [isAnimating, setIsAnimating] = useState(false)

    // handy function to split text into characters with support for unicode and emojis
    const splitIntoCharacters = (text: string): string[] => {
      if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
        const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
        return Array.from(segmenter.segment(text), ({ segment }) => segment)
      }
      // Fallback for browsers that don't support Intl.Segmenter
      return Array.from(text)
    }

    // Split text based on splitBy parameter
    const elements = useMemo(() => {
      const words = text.split(" ")
      if (splitBy === "characters") {
        return words.map((word, i) => ({
          characters: splitIntoCharacters(word),
          needsSpace: i !== words.length - 1,
        }))
      }
      return splitBy === "words"
        ? text.split(" ")
        : splitBy === "lines"
          ? text.split("\n")
          : text.split(splitBy)
    }, [text, splitBy])

    // Calculate stagger delays based on staggerFrom
    const getStaggerDelay = useCallback(
      (index: number) => {
        const total =
          splitBy === "characters"
            ? elements.reduce(
                (acc, word) =>
                  acc +
                  (typeof word === "string"
                    ? 1
                    : word.characters.length + (word.needsSpace ? 1 : 0)),
                0
              )
            : elements.length
        if (staggerFrom === "first") return index * staggerDuration
        if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
        if (staggerFrom === "center") {
          const center = Math.floor(total / 2)
          return Math.abs(center - index) * staggerDuration
        }
        if (staggerFrom === "random") {
          const randomIndex = Math.floor(Math.random() * total)
          return Math.abs(randomIndex - index) * staggerDuration
        }
        return Math.abs(staggerFrom - index) * staggerDuration
      },
      [elements.length, staggerFrom, staggerDuration]
    )

    const startAnimation = useCallback(() => {
      setIsAnimating(true)
      onStart?.()
    }, [onStart])

    // Expose the startAnimation function via ref
    useImperativeHandle(ref, () => ({
      startAnimation,
      reset: () => setIsAnimating(false),
    }))

    // Auto start animation
    useEffect(() => {
      if (autoStart) {
        startAnimation()
      }
    }, [autoStart])

    const variants = {
      hidden: { y: reverse ? "-100%" : "100%" },
      visible: (i: number) => ({
        y: 0,
        transition: {
          ...transition,
          delay: ((transition?.delay as number) || 0) + getStaggerDelay(i),
        },
      }),
    }

    return (
      <span
        className={cn(
          containerClassName,
          "flex flex-wrap whitespace-pre-wrap",
          splitBy === "lines" && "flex-col"
        )}
        onClick={onClick}
        ref={containerRef}
        {...props}
      >
        <span className="sr-only">{text}</span>

        {(splitBy === "characters"
          ? (elements as WordObject[])
          : (elements as string[]).map((el, i) => ({
              characters: [el],
              needsSpace: i !== elements.length - 1,
            }))
        ).map((wordObj, wordIndex, array) => {
          const previousCharsCount = array
            .slice(0, wordIndex)
            .reduce((sum, word) => sum + word.characters.length, 0)

          return (
            <span
              key={wordIndex}
              aria-hidden="true"
              className={cn("inline-flex overflow-hidden", wordLevelClassName)}
            >
              {wordObj.characters.map((char, charIndex) => (
                <span
                  className={cn(
                    elementLevelClassName,
                    "whitespace-pre-wrap relative"
                  )}
                  key={charIndex}
                >
                  <motion.span
                    custom={previousCharsCount + charIndex}
                    initial="hidden"
                    animate={isAnimating ? "visible" : "hidden"}
                    variants={variants}
                    onAnimationComplete={
                      wordIndex === elements.length - 1 &&
                      charIndex === wordObj.characters.length - 1
                        ? onComplete
                        : undefined
                    }
                    className="inline-block"
                  >
                    {char}
                  </motion.span>
                </span>
              ))}
              {wordObj.needsSpace && <span> </span>}
            </span>
          )
        })}
      </span>
    )
  }
)

VerticalCutReveal.displayName = "VerticalCutReveal"
export default VerticalCutReveal
src/fancy/examples/background/animated-gradient-demo.tsx
파일 저장

"use client"

import React from "react"

import AnimatedGradient from "@/fancy/components/background/animated-gradient-with-svg"

interface BentoCardProps {
  title: string
  subtitle?: string
  description?: string
  buttonText?: string
  align?: "left" | "center"
}

const gradientColors = ["#FF0000", "#FF4500", "#FF9900"]

const BentoCard: React.FC<BentoCardProps> = ({
  title,
  subtitle,
  description,
  buttonText,
  align = "left",
}) => (
  <div className="relative overflow-hidden rounded-2xl min-h-[120px] sm:min-h-[180px] h-full w-full flex flex-col justify-between p-4 sm:p-6 font-medium">
    <span className="absolute inset-0 z-0 pointer-events-none bg-[#ff592f]">
      <AnimatedGradient colors={gradientColors} speed={10} blur="medium" />
    </span>
    <div
      className={`relative z-10 flex-1 ${align === "center" ? "items-center text-center" : "items-start text-left"} flex flex-col justify-between w-full h-full`}
    >
      <div>
        <div className="text-white text-xs sm:text-sm md:text-base font-semibold -mb-0.5">
          {title}
        </div>
        {subtitle && (
          <div className="text-white/80 text-[10px] sm:text-xs md:text-sm mb-1 sm:mb-2">
            {subtitle}
          </div>
        )}
      </div>
      {description && (
        <div className="text-white text-[10px] sm:text-xs mt-auto mb-1 sm:mb-2 text-pretty leading-tight">{description}</div>
      )}
      {buttonText && (
        <button className="mt-2 sm:mt-4 px-2 sm:px-3 py-0.5 sm:py-1 rounded-full border border-white text-white text-[10px] sm:text-xs font-medium transition-colors cursor-pointer">
          {buttonText}
        </button>
      )}
    </div>
  </div>
)

const AnimatedGradientDemo: React.FC = () => {
  return (
    <div className="w-full h-full flex items-center justify-center bg-background px-20 sm:px-8 py-8 sm:py-16">
      <div className="grid grid-cols-1 sm:grid-cols-12 gap-2 w-full max-w-lg">
        {/* Top left card */}
        <div className="sm:col-span-8 h-32 sm:h-48">
          <BentoCard
            title="Animated Bento"
            subtitle="#001"
            description="Using only SVG circles and blur"
          />
        </div>
        {/* Top right card */}
        <div className="h-32 sm:h-48 sm:col-span-4">
          <BentoCard title="Gradients" buttonText="Explore More"  />
        </div>
      </div>
    </div>
  )
}

export default AnimatedGradientDemo
src/fancy/examples/background/pixel-trail-custom-pixel-demo.tsx
파일 저장

import useScreenSize from "@/hooks/use-screen-size"
import PixelTrail from "@/fancy/components/background/pixel-trail"

const PixelTrailDemo: React.FC = () => {
  const screenSize = useScreenSize()

  return (
    <div className="relative w-full h-full bg-white text-black flex flex-col font-calendas">
      <div className="absolute inset-0 z-0">
        <PixelTrail
          pixelSize={screenSize.lessThan(`md`) ? 14 : 20}
          fadeDuration={0}
          delay={600}
          pixelClassName="rounded-full bg-"
        />
      </div>

      <div className="justify-center items-center flex flex-col w-full h-full z-10 pointer-events-none space-y-2 md:space-y-4">
        <h2 className="text-xl cursor-pointer sm:text-3xl md:text-5xl tracking-tight">
          fancy ✽ components{" "}
        </h2>
        <p className="text-xs md:text-lg font-overused-grotesk">
          with react, motion, and typrscript.
        </p>
      </div>
    </div>
  )
}

export default PixelTrailDemo
src/fancy/examples/background/pixel-trail-demo.tsx
파일 저장

import useScreenSize from "@/hooks/use-screen-size"
import PixelTrail from "@/fancy/components/background/pixel-trail"

const PixelTrailDemo: React.FC = () => {
  const screenSize = useScreenSize()

  return (
    <div className="w-full h-full bg-black text-white flex flex-col font-azeret-mono">
      <div className="absolute inset-0 z-0">
        <PixelTrail
          pixelSize={screenSize.lessThan(`md`) ? 16 : 24}
          fadeDuration={500}
          pixelClassName="bg-white"
        />
      </div>

      <div className="justify-center items-center flex flex-col w-full h-full">
        <h2 className="font-tiny5 text-3xl sm:text-4xl md:text-6xl uppercase">
          FANCYCOMPONENTS.DEV
        </h2>
        <p className="pt-0.5 sm:pt-2 text-xs sm:text-base md:text-xl">
          Make the web fun again.
        </p>
      </div>
    </div>
  )
}

export default PixelTrailDemo
src/fancy/examples/background/pixel-trail-no-fade-demo.tsx
파일 저장

import Image from "next/image"

import PixelTrail from "@/fancy/components/background/pixel-trail"

const PixelTrailDemo: React.FC = () => {
  return (
    <div className="w-full h-full flex flex-col overflow-hidden">
      <Image
        src="https://images.unsplash.com/photo-1562016600-ece13e8ba570?q=80&w=2838&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
        alt="water surface"
        fill
        className="absolute inset-0 z-0 contrast-[70%]"
      />
      <div className="absolute inset-0 z-1">
        <PixelTrail
          pixelSize={20}
          delay={130}
          fadeDuration={0}
          pixelClassName="bg-[#f1ff76]"
        />
      </div>

      <ul className="flex w-full items-center font-bold space-x-6 px-6 py-4 text-[#f1ff76] z-10 justify-between text-2xl md:text-4xl">
        <a>WATER SUPPLY CO.</a>
        <a
          href=""
          className="hover:underline cursor-pointer  font-bold uppercase text-[#f1ff76]"
        >
          Menu
        </a>
      </ul>
      <div className="z-0 text-[#f1ff76] text-6xl mt-12 mx-6">
        <div className="flex flex-row items-center">
          <h2 className="font-tiny5 text-6xl md:text-9xl uppercase">100%</h2>
          <h2 className="text-5xl md:text-8xl ml-4 md:ml-8">purity</h2>
        </div>
        <p className="mt-3 text-base md:text-3xl">
          {
            "we deliver more than just hydration — we offer nature's purest refreshment, untouched by modern contaminants. Our water is sourced from deep, protected aquifers and naturally filtered through ancient rock layers, with unmatched clarity and taste."
          }
        </p>
      </div>
    </div>
  )
}

export default PixelTrailDemo
src/fancy/examples/blocks/circling-elements-demo.tsx
파일 저장

import React from "react"
import Image from "next/image"
import { exampleImages } from "@/utils/demo-images"

import useScreenSize from "@/hooks/use-screen-size"
import CirclingElements from "@/fancy/components/blocks/circling-elements"

const CirclingElementsDemo: React.FC = () => {
  const screenSize = useScreenSize()

  return (
    <div className="w-full h-full bg-[#efefef] flex items-center justify-center">
      <CirclingElements
        radius={screenSize.lessThan(`md`) ? 80 : 120}
        duration={10}
        easing="linear"
        pauseOnHover={true}
      >
        {exampleImages.map((image, index) => (
          <div
            key={index}
            className="w-20 h-20 md:w-28 md:h-28 hover:scale-125 duration-200 ease-out cursor-pointer"
          >
            <Image src={image.url} fill alt="image" className="object-cover" />
          </div>
        ))}
      </CirclingElements>
    </div>
  )
}

export default CirclingElementsDemo
src/fancy/examples/blocks/circling-elements-easing-demo.tsx
파일 저장

import React from "react"
import Image from "next/image"
import { exampleImages } from "@/utils/demo-images"

import useScreenSize from "@/hooks/use-screen-size"
import CirclingElements from "@/fancy/components/blocks/circling-elements"

const CirclingElementsDemo: React.FC = () => {
  const screenSize = useScreenSize()

  return (
    <div className="relative w-full h-full bg-[#efefef] flex items-center justify-center">
      <CirclingElements
        radius={screenSize.lessThan(`md`) ? 100 : 180}
        duration={8}
        direction="reverse"
        easing="0.944, 0.008, 0.147, 1.002"
      >
        {[...exampleImages, ...exampleImages].map((image, index) => (
          <div
            key={index}
            className="w-20 h-20 md:w-28 md:h-28 absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer hover:scale-125 duration-200 ease-out"
          >
            <Image
              src={image.url}
              fill
              alt="image"
              className="object-cover shadow-2xl "
            />
          </div>
        ))}
      </CirclingElements>
    </div>
  )
}

export default CirclingElementsDemo
src/fancy/examples/blocks/css-box-demo.tsx
파일 저장

import { useRef } from "react"

import CSSBox, { CSSBoxRef } from "@/fancy/components/blocks/css-box"

export default function CSSBoxDemo() {
  const boxRef = useRef<CSSBoxRef>(null)

  // Example text face component
  const TextFace = ({
    texts,
    className,
  }: {
    texts: string[]
    className?: string
  }) => (
    <div className={`flex flex-col ${className || ""}`}>
      {texts.map((text, i) => (
        <div key={i} className="text-primary-blue font-bold tracking-wider">
          {text}
        </div>
      ))}
    </div>
  )

  return (
    <CSSBox
      ref={boxRef}
      width={200}
      height={200}
      depth={200}
      perspective={600}
      stiffness={100}
      damping={30}
      faces={{
        front: (
          <TextFace
            texts={["YOU CAN", "JUST", "DO THINGS"]}
            className="text-right justify-end items-end h-full w-full p-2 select-none"
          />
        ),
        back: (
          <TextFace
            texts={["MAKE THINGS", "YOU WISH", "EXISTED"]} 
            className="text-left justify-end h-full w-full p-2 select-none"
          />
        ),
        right: (
          <TextFace
            texts={["MAKE THINGS", "YOU WISH", "EXISTED"]}
            className="text-left justify-end h-full w-full p-2 select-none"
          />
        ),
        left: (
          <TextFace
            texts={["BREAK", "THINGS", "MOVE", "FAST"]}
            className="items-end w-full h-full p-2 select-none"
          />
        ),
        top: (
          <TextFace
            texts={["YOU CAN", "JUST", "DO THINGS"]}
            className="text-right justify-end items-end h-full w-full p-2 select-none" 
          />
        ),
        bottom: (
          <TextFace
            texts={["BREAK", "THINGS", "MOVE", "FAST"]}
            className="items-end w-full h-full p-2 select-none"
          />
        ),
      }}
      className="text-3xl"
    />
  )
}
src/fancy/examples/blocks/css-box-hover-demo.tsx
파일 저장

import { useEffect, useRef } from "react"

import { cn } from "@/lib/utils"
import CSSBox, { CSSBoxRef } from "@/fancy/components/blocks/css-box"

const BoxText = ({
  children,
  className,
  i,
}: {
  children: React.ReactNode
  className?: string
  i: number
}) => (
  <div
    className={cn(
      "w-full h-full uppercase text-white flex items-center justify-center p-0 text-2xl md:text-3xl font-bold",
      className
    )}
  >
    {children}
  </div>
)

export default function CSSBoxHoverDemo() {
  const boxRefs = useRef<(CSSBoxRef | null)[]>([])
  const isRotating = useRef<boolean[]>([])
  const currentRotations = useRef<number[]>([])

  const boxes = [
    { text: "January 15, 2025", size: 300 },
    { text: "Live Q&A", size: 200 },
    { text: "10:00", size: 120 },
    { text: "to", size: 70 },
    { text: "11:30", size: 120 },
    { text: "CET", size: 120 },
    { text: "Online", size: 180 },
    { text: "Recording Available", size: 380 },
    { text: "In English", size: 220 },
    { text: "Register Now", size: 280 },
    { text: "Free Access", size: 240 },
  ]

  useEffect(() => {
    currentRotations.current = new Array(boxes.length).fill(0)
  }, [])

  const handleHover = async (index: number) => {
    if (isRotating.current[index]) return

    isRotating.current[index] = true
    const box = boxRefs.current[index]
    if (!box) return

    const nextRotation = currentRotations.current[index] + 90
    currentRotations.current[index] = nextRotation

    box.rotateTo(0, nextRotation)

    isRotating.current[index] = false
  }

  return (
    <div className="flex flex-col items-center justify-center w-full h-full bg-[#111]">
      {boxes.map(({ text, size }, index) => (
        <CSSBox
          key={index}
          ref={(el) => {
            if (el) {
              boxRefs.current[index] = el
              isRotating.current[index] = false
              currentRotations.current[index] = 0
            }
          }}
          width={size}
          height={35}
          depth={size}
          draggable={false}
          className="hover:z-10"
          onMouseEnter={() => handleHover(index)}
          faces={{
            front: <BoxText i={index}>{text}</BoxText>,
            back: (
              <BoxText i={index} className="">
                {text}
              </BoxText>
            ),
            left: <BoxText i={index}>{text}</BoxText>,
            right: (
              <BoxText i={index} className="">
                {text}
              </BoxText>
            ),
          }}
        />
      ))}
    </div>
  )
}
src/fancy/examples/blocks/css-box-non-uniform-demo.tsx
파일 저장

import { useEffect, useRef } from "react"

import { cn } from "@/lib/utils"
import CSSBox, { CSSBoxRef } from "@/fancy/components/blocks/css-box"

const BoxText = ({ className }: { className?: string }) => (
  <div
    className={cn(
      "w-full h-full text-white flex items-center justify-center p-2 text-sm font-bold bg-transparent",
      className
    )}
  >
    ANYTHING IS POSSIBLE
  </div>
)

export default function CSSBox2Demo() {
  const boxRefs = useRef<(CSSBoxRef | null)[]>([])

  useEffect(() => {
    const animate = async () => {
      const delay = (ms: number) =>
        new Promise((resolve) => setTimeout(resolve, ms))

      while (true) {
        // Rotate to right face
        for (let i = 0; i < boxRefs.current.length; i++) {
          boxRefs.current[i]?.showRight()
          await delay(50)
        }
        await delay(1000)

        // Rotate to back face
        for (let i = 0; i < boxRefs.current.length; i++) {
          boxRefs.current[i]?.showBack()
          await delay(50)
        }
        await delay(1000)

        // Rotate to left face
        for (let i = 0; i < boxRefs.current.length; i++) {
          boxRefs.current[i]?.showLeft()
          await delay(50)
        }
        await delay(1000)

        // Rotate to front face
        for (let i = 0; i < boxRefs.current.length; i++) {
          boxRefs.current[i]?.showFront()
          await delay(50)
        }
        await delay(1000)
      }
    }

    animate()
  }, [])

  return (
    <div className="flex flex-col items-center justify-center w-full h-full bg-black">
      <div>
        {[...Array(12)].map((_, index) => (
          <CSSBox
            key={index}
            ref={(el) => {
              if (el) boxRefs.current[index] = el
            }}
            width={200}
            height={30}
            depth={200}
            draggable={false}
            className="hover:z-10"
            faces={{
              front: <BoxText className=" border-white " />,
              back: <BoxText className=" border-white" />,
              left: <BoxText className=" border-white bg-white text-black" />,
              right: <BoxText className=" border-white bg-white text-black" />,
            }}
          />
        ))}
      </div>
    </div>
  )
}
src/fancy/examples/blocks/css-box-scroll-demo.tsx
파일 저장

import { useRef } from "react"
import { useScroll, useTransform } from "motion/react"

import { cn } from "@/lib/utils"
import useScreenSize from "@/hooks/use-screen-size"
import CSSBox, { CSSBoxRef } from "@/fancy/components/blocks/css-box"

const BoxFace = ({
  imageUrl,
  className,
}: {
  imageUrl: string
  className?: string
}) => (
  <div className={cn("w-full h-full relative", className)}>
    <img src={imageUrl} alt="" className="w-full h-full object-cover" />
    <div
      className="absolute inset-0"
      style={{
        maskImage: "linear-gradient(to top, white 20%, transparent 100%)",
        WebkitMaskImage: "linear-gradient(to top, white 20%, transparent 100%)",
        backgroundColor: "rgba(255,255,255,0.4)",
        backdropFilter: "blur(8px)",
      }}
    >
      <div className="absolute bottom-0 w-full flex flex-col items-start justify-end pb-3">
        <div className="flex w-full justify-between px-2 items-end">
          <div className="text-[8px] font-black pb-1">JUN14</div>
          <div className="text-3xl md:text-5xl font-black px-2">
            New Arrivals
          </div>
          <div className="text-lg md:text-xl font-medium">SS25</div>
        </div>
      </div>
    </div>
  </div>
)

export default function CSSBoxScrollDemo() {
  const boxRef = useRef<CSSBoxRef>(null)
  const containerRef = useRef<HTMLDivElement>(null)
  const screenSize = useScreenSize()

  const { scrollYProgress } = useScroll({
    container: containerRef,
  })

  // Transform scroll progress (0-1) to rotation (0-360)
  const rotation = useTransform(scrollYProgress, [0, 1], [0, 360])

  // Update box rotation when scroll transform changes
  rotation.on("change", (latest) => {
    boxRef.current?.rotateTo(0, latest)
  })

  const imageUrl =
    "https://cdn.cosmos.so/276cdd4e-8a7a-4c32-955f-83c5900a0926?format=jpeg"

  const boxWidth = screenSize.lessThan("md") ? 220 : 300
  const boxHeight = screenSize.lessThan("md") ? 300 : 400
  const boxDepth = screenSize.lessThan("md") ? 220 : 300

  return (
    <div
      ref={containerRef}
      className="relative w-full h-full overflow-y-auto bg-[#fefefe] flex"
    >
      <div className="h-[400%] flex w-full items-start justify-center absolute">
        <div className="sticky py-28 md:py-16 top-0 left-0 px-6 md:px-12">
          <CSSBox
            ref={boxRef}
            width={boxWidth}
            height={boxHeight}
            depth={boxDepth}
            perspective={2000}
            draggable={false}
            faces={{
              front: <BoxFace imageUrl={imageUrl} />,
              back: <BoxFace imageUrl={imageUrl} />,
              left: <BoxFace imageUrl={imageUrl} />,
              right: <BoxFace imageUrl={imageUrl} />,
            }}
          />
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/blocks/drag-elements-demo.tsx
파일 저장

import React from "react"
import Image from "next/image"

import useScreenSize from "@/hooks/use-screen-size"
import DragElements from "@/fancy/components/blocks/drag-elements"

const urls = [
  "https://images.unsplash.com/photo-1683746531526-3bca2bc901b8?q=80&w=1820&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
  "https://images.unsplash.com/photo-1631561729243-9b3291efceae?q=80&w=1885&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
  "https://images.unsplash.com/photo-1635434002329-8ab192fe01e1?q=80&w=2828&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
  "https://images.unsplash.com/photo-1719586799413-3f42bb2a132d?q=80&w=2048&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
  "https://images.unsplash.com/photo-1720561467986-ca3d408ca30b?q=80&w=2048&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
  "https://images.unsplash.com/photo-1724403124996-64115f38cd3f?q=80&w=3082&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
]

const randomInt = (min: number, max: number) => {
  return Math.floor(Math.random() * (max - min + 1)) + min
}

const DragElementsDemo: React.FC = () => {
  const screenSize = useScreenSize()
  return (
    <div className="w-full h-full relative bg-[#eeeeee] overflow-hidden">
      <h1 className="absolute text-xl md:text-4xl md:ml-36 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center text-muted-foreground uppercase w-full">
        all your
        <span className="font-bold text-foreground dark:text-muted">
          {" "}
          memories.{" "}
        </span>
      </h1>
      <DragElements dragMomentum={false} className="p-40">
        {urls.map((url, index) => {
          const rotation = randomInt(-12, 12)
          const width = screenSize.lessThan(`md`)
            ? randomInt(90, 120)
            : randomInt(120, 150)
          const height = screenSize.lessThan(`md`)
            ? randomInt(120, 140)
            : randomInt(150, 180)

          return (
            <div
              key={index}
              className={`flex items-start justify-center bg-white shadow-2xl p-4`}
              style={{
                transform: `rotate(${rotation}deg)`,
                width: `${width}px`,
                height: `${height}px`,
              }}
            >
              <div
                className={`relative overflow-hidden`}
                style={{
                  width: `${width - 4}px`,
                  height: `${height - 30}px`,
                }}
              >
                <Image
                  src={url}
                  fill
                  alt={`Analog photo ${index + 1}`}
                  className="object-cover"
                  draggable={false}
                />
              </div>
            </div>
          )
        })}
      </DragElements>
    </div>
  )
}

export default DragElementsDemo
src/fancy/examples/blocks/drag-elements-momentum-demo.tsx
파일 저장

import React from "react"

import DragElements from "@/fancy/components/blocks/drag-elements"

const DragElementsDemo: React.FC = () => {
  return (
    <div className="w-full h-full relative bg-teal text-teal overflow-hidden">
      <DragElements dragMomentum={true} className="p-30 md:p-40">
        <div className="text-2xl md:text-6xl px-6 py-3 md:px-8 md:py-4 rounded-full bg-primary-pink shadow-lg rotate-[-2deg] justify-center items-center">
          super fun ✿
        </div>
        <div className="text-2xl md:text-6xl px-6 py-3 md:px-8 md:py-4 rounded-full bg-primary-pink shadow-lg rotate-[2deg] justify-center items-center">
          funky time! ✴
        </div>
        <div className="text-2xl md:text-6xl px-6 py-3 md:px-8 md:py-4 rounded-full bg-primary-pink shadow-lg rotate-[-4deg] justify-center items-center">
          awesome ✺
        </div>
      </DragElements>
    </div>
  )
}

export default DragElementsDemo
src/fancy/examples/blocks/element-along-svg-path-demo.tsx
파일 저장

import { useRef } from "react"
import { exampleImages } from "@/utils/demo-images"

import ElementAlongPath, {
  ElementAlongPathItem,
} from "@/fancy/components/blocks/element-along-svg-path"

export default function ElementAlongPathDemo() {
  const containerRef = useRef<HTMLDivElement>(null)

  return (
    <div ref={containerRef} className="relative overflow-auto w-full h-full">
      <p className="h-[200%] absolute top-12 left-12 w-64 text-6xl">
        this week in the bag
      </p>
      <div className="sticky w-full h-full">
        <ElementAlongPath
          path={
            "M3.5002 223.998C-15 50.9992 95.8557 -60.0575 190 37.4991C259 109 274 139.999 444 139.999"
          }
          pathId="path-1"
          showPath={true}
          viewBox="0 0 444 225"
          scrollContainer={containerRef}
          animationType="auto"
          className="absolute w-full h-full top-0 left-0"
        >
          {[...Array(1)].map((_, i) => (
            <ElementAlongPathItem
              key={i}
              transition={{
                duration: 3,
                ease: [0.757, -0.002, 0.123, 0.993],
              }}
              className="pointer-events-auto absolute top-0 left-0"
            >
              <img
                src={exampleImages[i % exampleImages.length].url}
                alt={`Example ${i}`}
                className="w-20 h-20 object-cover shadow-2xl cursor-pointer hover:scale-105 duration-300 ease-in-out"
              />
            </ElementAlongPathItem>
          ))}
        </ElementAlongPath>
      </div>
    </div>
  )
}
src/fancy/examples/blocks/float-demo.tsx
파일 저장

"use client"

import { exampleImages } from "@/utils/demo-images"
import { motion } from "motion/react"

import Float from "@/fancy/components/blocks/float"

export default function FloatDemo() {
  return (
    <div className="w-full h-full flex flex-col items-center justify-center bg-white text-foreground dark:text-muted">
      <div className="flex flex-col items-center justify-center w-full h-full">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.25, delay: 0.5, ease: "easeOut" }}
        >
          <Float>
            <div className="sm:w-40 sm:h-40 h-32 w-32 md:w-48 md:h-48 shadow-2xl relative overflow-hidden  hover:scale-105 duration-200 cursor-pointer transition-transform">
              <img
                src={exampleImages[4].url}
                className="w-full h-full object-cover absolute top-0 left-0"
              />
            </div>
          </Float>
        </motion.div>
        <motion.h2
          className="pt-8 sm:pt-12 md:pt-16 text-xl sm:text-3xl md:text-4xl uppercase z-10"
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.25, delay: 0.7, ease: "easeOut" }}
        >
          Album of the week
        </motion.h2>
      </div>
    </div>
  )
}
src/fancy/examples/blocks/float-offset-demo.tsx
파일 저장

import { cn } from "@/lib/utils"
import Float from "@/fancy/components/blocks/float"

export default function FloatDemo() {
  const texts = [
    { text: "@mdx-js/loader", position: "top-[0%] left-[20%]" },
    { text: "@mdx-js/react", position: "top-[20%] left-[80%]" },
    { text: "@next/mdx", position: "top-[70%] left-[40%]" },
    { text: "@vercel/analytics", position: "top-[80%] left-[30%]" },
    { text: "class-variance-authority", position: "top-[40%] left-[0%]" },
    { text: "clsx", position: "top-[15%] left-[45%]" },
    { text: "flubber", position: "top-[65%] left-[85%]" },
    { text: "motion", position: "top-[85%] left-[15%]" },
    { text: "lenis", position: "top-[35%] left-[75%]" },
    { text: "lodash", position: "top-[75%] left-[55%]" },
    { text: "lucide-react", position: "top-[25%] left-[35%]" },
    { text: "matter-js", position: "top-[45%] left-[25%]" },
    { text: "mdast-util-toc", position: "top-[55%] left-[65%]" },
    { text: "next", position: "top-[90%] left-[45%]" },
    { text: "next-mdx-remote", position: "top-[10%] left-[70%]" },
    { text: "poly-decomp", position: "top-[60%] left-[10%]" },
    { text: "react", position: "top-[30%] left-[50%]" },
    { text: "react-dom", position: "top-[95%] left-[60%]" },
    { text: "react-syntax-highlighter", position: "top-[5%] left-[90%]" },
    { text: "react-wrap-balancer", position: "top-[82%] left-[75%]" },
    { text: "rehype-pretty-code", position: "top-[28%] left-[15%]" },
    { text: "remark", position: "top-[67%] left-[5%]" },
    { text: "svg-path-commander", position: "top-[92%] left-[25%]" },
    { text: "tailwind-merge", position: "top-[28%] left-[95%]" },
    { text: "tailwindcss-animate", position: "top-[73%] left-[20%]" },
    { text: "zod", position: "top-[8%] left-[40%]" },
  ]

  return (
    <div className="w-full h-full flex flex-col items-center justify-center bg-white relative">
      {texts.map((item, i) => (
        <Float
          key={i}
          timeOffset={i * 0.8}
          amplitude={[
            15 + Math.random() * 20,
            25 + Math.random() * 30,
            20 + Math.random() * 25,
          ]}
          rotationRange={[
            10 + Math.random() * 10,
            10 + Math.random() * 10,
            5 + Math.random() * 5,
          ]}
          speed={0.3 + Math.random() * 0.4}
          className={cn(
            "absolute text-lg flex sm:text-xl md:text-2xl font-light hover:underline cursor-pointer text-primary-blue",
            item.position
          )}
        >
          <p>{item.text}</p>
        </Float>
      ))}
    </div>
  )
}
src/fancy/examples/blocks/marquee-along-svg-path-demo.tsx
파일 저장

import MarqueeAlongSvgPath from "@/fancy/components/blocks/marquee-along-svg-path"

const path =
  "M1 209.434C58.5872 255.935 387.926 325.938 482.583 209.434C600.905 63.8051 525.516 -43.2211 427.332 19.9613C329.149 83.1436 352.902 242.723 515.041 267.302C644.752 286.966 943.56 181.94 995 156.5"

export default function MarqueeAlongSvgPathDemo() {
  return (
    <div className="w-full h-full bg-zinc-50 flex items-center justify-center">
      <MarqueeAlongSvgPath
        path={path}
        viewBox="0 0 996 330"
        baseVelocity={8}
        slowdownOnHover={true}
        draggable={true}
        repeat={2}
        dragSensitivity={0.1}
        className="w-full h-full scale-105"
        responsive
        grabCursor
      >
        {imgs.map((img, i) => (
          <div
            key={i}
            className="w-14 h-full hover:scale-150 duration-300 ease-in-out"
          >
            <img
              src={img.src}
              alt={`Example ${i}`}
              className="w-full h-full object-cover"
              draggable={false}
            />
          </div>
        ))}
      </MarqueeAlongSvgPath>
    </div>
  )
}

const imgs = [
  {
    src: "https://cdn.cosmos.so/b9909337-7a53-48bc-9672-33fbd0f040a1?format=jpeg",
    link: "https://www.instagram.com/p/DCOl6YTS85e/?igsh=MXNvdHhyczl1djJ6ZA%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/ecdc9dd7-2862-4c28-abb1-dcc0947390f3?format=jpeg",
    link: "https://www.instagram.com/p/C4RTJvVpP4R/?igsh=MWZwOTNlYTVodGszMw%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/79de41ec-baa4-4ac0-a9a4-c090005ca640?format=jpeg",
    link: "https://pangrampangram.com/products/mori",
  },
  {
    src: "https://cdn.cosmos.so/1a18b312-21cd-4484-bce5-9fb7ed1c5e01?format=jpeg",
    link: "https://www.sergidelgado.com/selected-work/ampersand",
  },
  {
    src: "https://cdn.cosmos.so/d765f64f-7a66-462f-8b2d-3d7bc8d7db55?format=jpeg",
    link: "https://www.instagram.com/p/C40XmANsoe_/?igsh=MXFlZGx4cmw3ZW1qYw%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/6b9f08ea-f0c5-471f-a620-71221ff1fb65?format=jpeg",
    link: "https://abduzeedo.com/super-stylish-type-explorations",
  },
  {
    src: "https://cdn.cosmos.so/40a09525-4b00-4666-86f0-3c45f5d77605?format=jpeg",
    link: "https://www.instagram.com/p/CrhdrGjr9yK/?igshid=MTc4MmM1YmI2Ng%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/14f05ab6-b4d0-4605-9007-8a2190a249d0?format=jpeg",
    link: "https://www.instagram.com/julian.stiber/p/By5RBApiDzE/?img_index=1",
  },
  {
    src: "https://cdn.cosmos.so/d05009a2-a2f8-4a4c-a0de-e1b0379dddb8?format=jpeg",
    link: "https://www.instagram.com/p/CeT3COysRNN/?img_index=2",
  },
  {
    src: "https://cdn.cosmos.so/ba646e35-efc2-494a-961b-b40f597e6fc9?format=jpeg",
    link: "https://www.instagram.com/godfreydadich/",
  },
  {
    src: "https://cdn.cosmos.so/e899f9c3-ed48-4899-8c16-fbd5a60705da?format=jpeg",
    link: "https://www.instagram.com/p/Bty1U6BhTOW/?img_index=5",
  },
  {
    src: "https://cdn.cosmos.so/24e83c11-c607-45cd-88fb-5059960b56a0?format=jpeg",
    link: "https://www.instagram.com/p/C48dxn1LqhC/?igsh=dmV5ZWR0Z2Y3Zzlt&img_index=3",
  },
  {
    src: "https://cdn.cosmos.so/cd346bce-f415-4ea7-8060-99c5f7c1741a?format=jpeg",
    link: "https://www.instagram.com/p/C08ZDVyyRhK/?img_index=2&igsh=bHAyZjcxYW1jZDNu",
  },
]
src/fancy/examples/blocks/marquee-along-svg-path-mapping-demo.tsx
파일 저장

import MarqueeAlongSvgPath from "@/fancy/components/blocks/marquee-along-svg-path"

function generateSpiralPath(turns = 5, centerX = 500, centerY = 137) {
  const points = []
  const numPoints = turns * 300 // number of points to create smooth spiral
  const spacing = 18 // controls how far apart the spiral arms are

  for (let i = 0; i < numPoints; i++) {
    const angle = (i / numPoints) * turns * 2 * Math.PI
    const radius = spacing * angle // radius increases with angle
    const x = centerX + radius * Math.cos(angle)
    const y = centerY + radius * Math.sin(angle)
    points.push(`${i === 0 ? "M" : "L"} ${x} ${y}`)
  }

  return points.join(" ")
}

const path = generateSpiralPath(4)

export default function MarqueeAlongSvgPathDemo() {
  return (
    <div className="w-full h-full bg-zinc-50 flex items-center justify-center">
      <h2 className="text-black text-6xl sm:text-8xl z-10">fancy</h2>
      <MarqueeAlongSvgPath
        path={path}
        viewBox="0 0 400 474"
        baseVelocity={1}
        showPath={false}
        slowdownOnHover={true}
        repeat={8}
        enableRollingZIndex={false}
        dragSensitivity={0.01}
        className="absolute w-full h-full transform-3d will-change-transform"
        responsive
        cssVariableInterpolation={[
          { property: "opacity", from: 0, to: 1.5 },
          { property: "scale", from: 0.1, to: 1 },
        ]}
        grabCursor
      >
        {imgs.map((img, i) => (
          <a
            href={img.link}
            target="_blank"
            rel="noopener noreferrer"
            className="pointer-events-auto"
          >
            <div
              key={i}
              className="w-14 h-full cursor-pointer hover:rotate-y-0 duration-300 ease-in-out hover:scale-200 will-change-transform"
            >
              <img
                src={img.src}
                alt={`Example ${i}`}
                className="w-full h-full object-cover"
                draggable={false}
              />
            </div>
          </a>
        ))}
      </MarqueeAlongSvgPath>
    </div>
  )
}

// EXAMPLE IMAGES
const imgs = [
  {
    src: "https://cdn.cosmos.so/b9909337-7a53-48bc-9672-33fbd0f040a1?format=jpeg",
    link: "https://www.instagram.com/p/DCOl6YTS85e/?igsh=MXNvdHhyczl1djJ6ZA%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/ecdc9dd7-2862-4c28-abb1-dcc0947390f3?format=jpeg",
    link: "https://www.instagram.com/p/C4RTJvVpP4R/?igsh=MWZwOTNlYTVodGszMw%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/79de41ec-baa4-4ac0-a9a4-c090005ca640?format=jpeg",
    link: "https://pangrampangram.com/products/mori",
  },
  {
    src: "https://cdn.cosmos.so/1a18b312-21cd-4484-bce5-9fb7ed1c5e01?format=jpeg",
    link: "https://www.sergidelgado.com/selected-work/ampersand",
  },
  {
    src: "https://cdn.cosmos.so/d765f64f-7a66-462f-8b2d-3d7bc8d7db55?format=jpeg",
    link: "https://www.instagram.com/p/C40XmANsoe_/?igsh=MXFlZGx4cmw3ZW1qYw%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/6b9f08ea-f0c5-471f-a620-71221ff1fb65?format=jpeg",
    link: "https://abduzeedo.com/super-stylish-type-explorations",
  },
  {
    src: "https://cdn.cosmos.so/40a09525-4b00-4666-86f0-3c45f5d77605?format=jpeg",
    link: "https://www.instagram.com/p/CrhdrGjr9yK/?igshid=MTc4MmM1YmI2Ng%3D%3D",
  },
  {
    src: "https://cdn.cosmos.so/14f05ab6-b4d0-4605-9007-8a2190a249d0?format=jpeg",
    link: "https://www.instagram.com/julian.stiber/p/By5RBApiDzE/?img_index=1",
  },
  {
    src: "https://cdn.cosmos.so/d05009a2-a2f8-4a4c-a0de-e1b0379dddb8?format=jpeg",
    link: "https://www.instagram.com/p/CeT3COysRNN/?img_index=2",
  },
  {
    src: "https://cdn.cosmos.so/ba646e35-efc2-494a-961b-b40f597e6fc9?format=jpeg",
    link: "https://www.instagram.com/godfreydadich/",
  },
  {
    src: "https://cdn.cosmos.so/e899f9c3-ed48-4899-8c16-fbd5a60705da?format=jpeg",
    link: "https://www.instagram.com/p/Bty1U6BhTOW/?img_index=5",
  },
  {
    src: "https://cdn.cosmos.so/24e83c11-c607-45cd-88fb-5059960b56a0?format=jpeg",
    link: "https://www.instagram.com/p/C48dxn1LqhC/?igsh=dmV5ZWR0Z2Y3Zzlt&img_index=3",
  },
  {
    src: "https://cdn.cosmos.so/cd346bce-f415-4ea7-8060-99c5f7c1741a?format=jpeg",
    link: "https://www.instagram.com/p/C08ZDVyyRhK/?img_index=2&igsh=bHAyZjcxYW1jZDNu",
  },
]
src/fancy/examples/blocks/marquee-along-svg-path-scroll-demo.tsx
파일 저장

import { useState } from "react"

import MarqueeAlongSvgPath from "@/fancy/components/blocks/marquee-along-svg-path"

const imgs = [
  "https://cdn.cosmos.so/b9909337-7a53-48bc-9672-33fbd0f040a1?format=jpeg",
  "https://cdn.cosmos.so/ecdc9dd7-2862-4c28-abb1-dcc0947390f3?format=jpeg",
  "https://cdn.cosmos.so/79de41ec-baa4-4ac0-a9a4-c090005ca640?format=jpeg",
  "https://cdn.cosmos.so/1a18b312-21cd-4484-bce5-9fb7ed1c5e01?format=jpeg",
  "https://cdn.cosmos.so/d765f64f-7a66-462f-8b2d-3d7bc8d7db55?format=jpeg",
  "https://cdn.cosmos.so/6b9f08ea-f0c5-471f-a620-71221ff1fb65?format=jpeg",
  "https://cdn.cosmos.so/40a09525-4b00-4666-86f0-3c45f5d77605?format=jpeg",
  "https://cdn.cosmos.so/14f05ab6-b4d0-4605-9007-8a2190a249d0?format=jpeg",
  "https://cdn.cosmos.so/d05009a2-a2f8-4a4c-a0de-e1b0379dddb8?format=jpeg",
  "https://cdn.cosmos.so/ba646e35-efc2-494a-961b-b40f597e6fc9?format=jpeg",
  "https://cdn.cosmos.so/e899f9c3-ed48-4899-8c16-fbd5a60705da?format=jpeg",
  "https://cdn.cosmos.so/24e83c11-c607-45cd-88fb-5059960b56a0?format=jpeg",
  "https://cdn.cosmos.so/cd346bce-f415-4ea7-8060-99c5f7c1741a?format=jpeg",
]

const path =
  "M1.12756 531.57C28.0893 516.8 74.8013 483.241 115.862 435.167M115.862 435.167C142.71 403.734 167.142 366.095 182.056 323.447C229.212 188.604 -65.6747 303.582 53.6794 397.09C73.8056 412.858 94.5052 425.626 115.862 435.167ZM115.862 435.167C221.157 482.211 342.426 450.85 489.709 314.125C517.752 288.093 540.139 265.319 557.876 245.305M557.876 245.305C652.19 138.884 615.024 110.493 597.546 85.1004C576.782 54.9327 401.867 14.2899 417.559 188.351C424.308 263.214 481.985 261.608 557.876 245.305ZM557.876 245.305C646.667 226.232 760.389 187.041 846.65 226.667M846.65 226.667C858.081 231.918 869.031 238.554 879.376 246.804C1034.5 370.518 957.576 540.884 843.253 562.658C768.137 576.964 767.606 395.943 846.65 226.667ZM846.65 226.667C887.908 138.309 950.848 53.1511 1036.18 0.642822"

export default function MarqueeAlongSvgPathDemo() {
  const [container, setContainer] = useState<HTMLElement | null>(null)

  return (
    <div
      className="w-full h-full relative bg-zinc-50 flex justify-center overflow-auto"
      ref={(node) => setContainer(node)}
    >
      <h2 className="mt-36 text-4xl">scroll down</h2>
      <div className="absolute h-[120%] sm:h-[150%] top-40 w-full justify-center items-center flex flex-col space-y-2 sm:space-y-3 md:space-y-4">
        <MarqueeAlongSvgPath
          path={path}
          viewBox="0 0 1040 570"
          baseVelocity={4}
          showPath={false}
          slowdownOnHover={true}
          draggable={true}
          dragAwareDirection
          dragVelocityDecay={0.98}
          scrollAwareDirection={true}
          useScrollVelocity={true}
          scrollContainer={{ current: container }}
          repeat={4}
          enableRollingZIndex={true}
          dragSensitivity={0.01}
          className="absolute top-0 w-full h-full"
          responsive
          grabCursor
        >
          {imgs.map((img, i) => (
            <div key={i} className="w-14 h-full cursor-pointer">
              <img
                src={img}
                alt={`Example ${i}`}
                className="w-full h-full object-cover"
                draggable={false}
              />
            </div>
          ))}
        </MarqueeAlongSvgPath>
      </div>
    </div>
  );
}
src/fancy/examples/blocks/media-between-text-demo.tsx
파일 저장

import useScreenSize from "@/hooks/use-screen-size"
import MediaBetweenText from "@/fancy/components/blocks/media-between-text"

export default function Preview() {
  const screenSize = useScreenSize()

  return (
    <div className="w-full h-full flex flex-col items-center justify-center bg-background">
      <a
        href="https://www.instagram.com/p/C3oL4euoc2l/?img_index=1"
        target="_blank"
        rel="noreferrer"
      >
        <MediaBetweenText
          firstText="that's a nice ("
          secondText=") chair!"
          mediaUrl={
            "https://cdn.cosmos.so/90e2192e-7bd4-44af-96ae-05cd955c0cfb?format=jpeg"
          }
          mediaType="image"
          triggerType="hover"
          mediaContainerClassName="w-full h-[30px] sm:h-[100px] overflow-hidden mx-px mt-1 sm:mx-2 sm:mt-4"
          className="cursor-pointer sm:text-6xl text-2xl text-primary-red lowercase font-light flex flex-row items-center justify-center w-full"
          animationVariants={{
            initial: { width: 0 },
            animate: {
              width: screenSize.lessThan("sm") ? "30px" : "100px",
              transition: { duration: 0.4, type: "spring", bounce: 0 },
            },
          }}
        />
      </a>
    </div>
  )
}
src/fancy/examples/blocks/media-between-text-scroll-demo.tsx
파일 저장

import React from "react"

import useScreenSize from "@/hooks/use-screen-size"
import MediaBetweenText from "@/fancy/components/blocks/media-between-text"

const elements = [
  {
    src: "https://cdn.cosmos.so/53454cbe-a4ec-4782-923f-a82d70e12645.mp4",
    left: "Tim",
    right: "Rodenböker",
    url: "https://www.instagram.com/tim_rodenbroeker/",
  },
  {
    src: "https://cdn.cosmos.so/499ddb3b-57cf-4c07-996c-f797fadf64ab.mp4",
    left: "Simon ",
    right: "Alexander-Adams",
    url: "https://www.instagram.com/polyhop/",
  },
  {
    src: "https://cdn.cosmos.so/444e4a2a-45a6-477f-b342-6b6bc9a7c53b.mp4",
    left: "Andreion",
    right: "de Castro",
    url: "https://www.instagram.com/andreiongd/",
  },
  {
    src: "https://cdn.cosmos.so/f533f1a8-9f67-4360-b395-7abc8594cac9.mp4",
    left: "Lorraine",
    right: "Li",
    url: "https://www.instagram.com/lorrr.l/",
  },
]

export default function MediaBetweenTextScrollDemo() {
  const ref = React.useRef<HTMLDivElement>(null)
  const screenSize = useScreenSize()

  return (
    <div
      className="w-full h-full items-center justify-center bg-background overflow-auto"
      ref={ref}
    >
      <div className="h-full relative w-full flex">
        <h3 className="text-5xl sm:text-8xl tracking-wide absolute sm:bottom-12 sm:left-12 bottom-4 left-4 w-64">
          today's inspo
        </h3>
        <p className="bottom-4 right-4 sm:right-12 sm:bottom-12 absolute ">
          Scroll down ↓
        </p>
      </div>

      <div className="h-full w-full flex flex-col space-y-12 mt-24 justify-center items-center text-6xl px-6">
        {elements.map((element, index) => (
          <a href={element.url} target="_blank" rel="noreferrer">
            <MediaBetweenText
              key={index}
              firstText={element.left}
              secondText={element.right}
              mediaUrl={element.src}
              mediaType="video"
              triggerType="inView"
              useInViewOptionsProp={{
                once: false,
                amount: 1,
                root: ref,
                margin: "-5% 0px -0% 0px",
              }}
              containerRef={ref}
              mediaContainerClassName="w-full h-[40px] sm:h-[80px] overflow-hidden mx-1 sm:mx-3 mt-1 sm:mt-4"
              className="cursor-pointer text-lg sm:text-4xl font-light flex flex-row items-center justify-center"
              animationVariants={{
                initial: { width: 0 },
                animate: {
                  width: screenSize.lessThan("sm") ? "40px" : "100px",
                  transition: {
                    duration: 1,
                    type: "spring",
                    bounce: 0,
                    delay: 0.1,
                  },
                },
              }}
            />
          </a>
        ))}
      </div>
    </div>
  )
}
src/fancy/examples/blocks/media-between-text-vertical-demo.tsx
파일 저장

import { useRef, useState } from "react"

import useScreenSize from "@/hooks/use-screen-size"
import { Button } from "@/components/ui/button"
import MediaBetweenText, {
  MediaBetweenTextRef,
} from "@/fancy/components/blocks/media-between-text"

export default function Preview() {
  const ref = useRef<MediaBetweenTextRef>(null)
  const [isOpen, setIsOpen] = useState(false)
  const screenSize = useScreenSize()

  return (
    <div className="w-full h-full flex flex-col items-center justify-center bg-background">
      <Button
        onClick={() => {
          setIsOpen(!isOpen)
          if (!isOpen) {
            ref.current?.animate()
          } else {
            ref.current?.reset()
          }
        }}
        size={"sm"}
        variant={"outline"}
        className="absolute top-4 left-4 h-8"
      >
        {isOpen ? "Close" : "Open"}
      </Button>

      <MediaBetweenText
        firstText="Artificial "
        secondText="Intelligence"
        mediaUrl={
          "https://cdn.cosmos.so/47c0223f-c704-4d5a-8b47-c48262ebe301?format=jpeg"
        }
        mediaType="image"
        triggerType="ref"
        ref={ref}
        mediaContainerClassName="w-full h-[60px] sm:h-[100px] overflow-hidden pt-1"
        className="cursor-pointer text-3xl sm:text-7xl font-calendas flex flex-col font-light items-center justify-center"
        leftTextClassName=""
        rightTextClassName="italic"
        animationVariants={{
          initial: {
            width: screenSize.lessThan("sm") ? "160px" : "280px",
            height: 0,
            transition: { duration: 0.7, ease: [0.944, 0.008, 0.147, 1.002] },
          },
          animate: {
            width: screenSize.lessThan("sm") ? "200px" : "330px",
            height: screenSize.lessThan("sm") ? "200px" : "300px",
            transition: { duration: 0.7, ease: [0.944, 0.008, 0.147, 1.002] },
          },
        }}
      />
    </div>
  )
}
src/fancy/examples/blocks/screensaver-demo.tsx
파일 저장

import React from "react"
import { exampleImages } from "@/utils/demo-images"

import Screensaver from "@/fancy/components/blocks/screensaver"

const CirclingElementsDemo: React.FC = () => {
  const containerRef = React.useRef<HTMLDivElement>(null)

  return (
    <div
      className="w-full h-full bg-[#efefef] overflow-hidden flex items-center justify-center relative text-foreground dark:text-muted"
      ref={containerRef}
    >
      <h1 className="z-30 text-3xl md:text-6xl font-overused-grotesk">
        page not found
      </h1>
      {[...exampleImages, ...exampleImages].map((image, index) => (
        <Screensaver
          key={index}
          speed={1}
          startPosition={{ x: index * 3, y: index * 3 }}
          startAngle={40}
          containerRef={containerRef}
        >
          <div className="w-20 h-20 md:w-48 md:h-48 overflow-hidden">
            <img
              src={image.url}
              alt={`Example ${index + 1}`}
              className="w-full h-full object-cover"
            />
          </div>
        </Screensaver>
      ))}
    </div>
  )
}

export default CirclingElementsDemo
src/fancy/examples/blocks/simple-marquee-3d-demo.tsx
파일 저장

import React, { useEffect, useRef, useState } from "react"
import { motion, Variants } from "motion/react"

import { cn } from "@/lib/utils"
import SimpleMarquee from "@/fancy/components/blocks/simple-marquee"

// Interface for album data
interface Album {
  coverArt: string
  title: string
  artist: string
}

const hardcodedAlbums: Album[] = [
  {
    coverArt:
      "https://ia600207.us.archive.org/28/items/mbid-770b9b80-10e1-4297-b1fd-46ad0dbb0305/mbid-770b9b80-10e1-4297-b1fd-46ad0dbb0305-1148987477_thumb500.jpg",
    title: "Homework",
    artist: "Daft Punk",
  },
  {
    coverArt:
      "https://ia800905.us.archive.org/5/items/mbid-9da1a863-f3f2-4618-bdce-f0c88c055ba5/mbid-9da1a863-f3f2-4618-bdce-f0c88c055ba5-8201721911_thumb500.jpg",
    title: "✝",
    artist: "Justice",
  },
  {
    coverArt:
      "https://ia800909.us.archive.org/12/items/mbid-ee618541-23df-4973-afb7-e2d9f02e03d8/mbid-ee618541-23df-4973-afb7-e2d9f02e03d8-8154031977_thumb500.jpg",
    title: "By Your Side",
    artist: "Breakbot",
  },
  {
    coverArt:
      "https://ia800804.us.archive.org/20/items/mbid-3adfe4c6-0fa2-4813-a212-058d9a99b4a8/mbid-3adfe4c6-0fa2-4813-a212-058d9a99b4a8-16639897570_thumb500.jpg",
    title: "Still Waters",
    artist: "Breakbot",
  },
  {
    coverArt:
      "https://ia803403.us.archive.org/14/items/mbid-a7fcead9-ab9d-3d15-bb0d-a2b1945517dd/mbid-a7fcead9-ab9d-3d15-bb0d-a2b1945517dd-8093147470_thumb500.jpg",
    title: "Fancy Footwork",
    artist: "Chromeo",
  },
  {
    coverArt:
      "https://ia801301.us.archive.org/18/items/mbid-8acb4d6d-2cf9-4685-b4e8-5c9937621691/mbid-8acb4d6d-2cf9-4685-b4e8-5c9937621691-5651042668_thumb500.jpg",
    title: "Trax on da Rocks Vol. 2",
    artist: "Thomas Bangalter",
  },
  {
    coverArt:
      "https://ia904509.us.archive.org/32/items/mbid-cb844a4d-c02f-3199-b949-1656b36722da/mbid-cb844a4d-c02f-3199-b949-1656b36722da-8145217760_thumb500.jpg",
    title: "1999",
    artist: "Cassius",
  },
  {
    coverArt:
      "https://ia903201.us.archive.org/6/items/mbid-747ed90c-6479-4cec-a98a-b320a5ef75be/mbid-747ed90c-6479-4cec-a98a-b320a5ef75be-18417637214_thumb500.jpg",
    title: "Woman",
    artist: "Justice",
  },
  {
    coverArt:
      "https://ia800200.us.archive.org/5/items/mbid-9d0a791d-c0ed-4b99-bb31-976fad672408/mbid-9d0a791d-c0ed-4b99-bb31-976fad672408-1959533822_thumb500.jpg",
    title: "Modjo",
    artist: "Modjo",
  },
  {
    coverArt:
      "https://ia903106.us.archive.org/23/items/mbid-bbfc83ad-826f-4957-893d-a808105c828b/mbid-bbfc83ad-826f-4957-893d-a808105c828b-25063975521_thumb500.jpg",
    title: "Random Access Memories",
    artist: "Daft Punk",
  },
]

export default function SimpleMarqueeDemo() {
  const [albums, setAlbums] = useState<Album[]>([])
  const [loading, setLoading] = useState(true)
  const container = useRef<HTMLDivElement>(null)

  useEffect(() => {
    // Simulate loading time
    const timer = setTimeout(() => {
      setAlbums(hardcodedAlbums)
      setLoading(false)
    }, 1000)

    return () => clearTimeout(timer)
  }, [])

  const firstRow = albums.slice(0, Math.floor(albums.length / 2))
  const secondRow = albums.slice(Math.floor(albums.length / 2))

  const MarqueeItem = ({ album, index }: { album: Album; index: number }) => {
    const variants = {
      initial: {
        y: "0px",
        x: "0px",
        scale: 1,
        opacity: 1,
      },
      hover: {
        y: "-12px",
        x: "-12px",
        scale: 1.05,
        transition: {
          duration: 0.15,
          ease: "easeOut",
        },
      },
    }

    const textVariants = {
      initial: {
        opacity: 0,
      },
      hover: {
        opacity: 1,
        transition: {
          duration: 0.15,
          ease: "easeOut",
        },
      },
    }

    const imageVariants = {
      initial: {
        opacity: 1,
      },
      hover: {
        opacity: 0.45,
        transition: {
          duration: 0.15,
          ease: "easeOut",
        },
      },
    }

    const containerClasses = cn(
      "mx-2 sm:mx-3 md:mx-4 cursor-pointer",
      "h-32 w-32 sm:h-40 sm:w-40 md:h-48 md:w-48",
      "relative flex shadow-white/20 shadow-md",
      "overflow-hidden flex-col transform-gpu bg-black"
    )

    const textContainerClasses = cn(
      "justify-end p-2 sm:p-2.5 md:p-3 h-full flex items-start flex-col",
      "leading-tight"
    )

    const imageClasses = cn("object-cover w-full h-full shadow-2xl absolute")

    return (
      <motion.div
        className={containerClasses}
        initial="initial"
        whileHover="hover"
        variants={variants as Variants}
      >
        <motion.div className={textContainerClasses} variants={textVariants as Variants}>
          <h3 className="text-white text-sm sm:text-base md:text-lg font-medium z-30">
            {album.title}
          </h3>
          <p className="text-neutral-300 text-xs sm:text-sm md:text-base z-30">
            {album.artist}
          </p>
        </motion.div>
        <motion.img
          src={album.coverArt}
          alt={`${album.title} by ${album.artist}`}
          draggable={false}
          className={imageClasses}
          variants={imageVariants as Variants}
        />
      </motion.div>
    )
  }

  return (
    <div
      className="flex w-full h-full relative justify-center items-center flex-col bg-black overflow-y-auto overflow-x-hidden"
      ref={container}
    >
      <h1 className="absolute text-center text-3xl sm:text-5xl md:text-6xl top-1/4 text-white font-calendas">
        Weekly Mix
      </h1>
      {loading ? (
        <div className="text-white">Loading album covers...</div>
      ) : (
        <>
          <div
            className="absolute h-1/2 sm:h-full w-[200%] top-32 -left-3/4 justify-center items-center flex flex-col space-y-2 sm:space-y-3 md:space-y-4 perspective-near"
            style={{
              transform:
                "rotateX(45deg) rotateY(-15deg) rotateZ(35deg) translateZ(-200px)",
            }}
          >
            <SimpleMarquee
              className="w-full"
              baseVelocity={10}
              repeat={3}
              draggable={false}
              scrollSpringConfig={{ damping: 50, stiffness: 400 }}
              slowDownFactor={0.2}
              slowdownOnHover
              slowDownSpringConfig={{ damping: 60, stiffness: 300 }}
              scrollAwareDirection={true}
              scrollContainer={container}
              useScrollVelocity={true}
              direction="left"
            >
              {firstRow.map((album, i) => (
                <MarqueeItem key={i} index={i} album={album} />
              ))}
            </SimpleMarquee>

            <SimpleMarquee
              className="w-full"
              baseVelocity={10}
              repeat={3}
              scrollAwareDirection={true}
              scrollSpringConfig={{ damping: 50, stiffness: 400 }}
              slowdownOnHover
              slowDownFactor={0.2}
              slowDownSpringConfig={{ damping: 60, stiffness: 300 }}
              useScrollVelocity={true}
              scrollContainer={container}
              draggable={false}
              direction="right"
            >
              {secondRow.map((album, i) => (
                <MarqueeItem key={i} index={i} album={album} />
              ))}
            </SimpleMarquee>
          </div>
        </>
      )}
    </div>
  );
}
src/fancy/examples/blocks/simple-marquee-demo.tsx
파일 저장

import React, { useRef } from "react"

import SimpleMarquee from "@/fancy/components/blocks/simple-marquee"

const exampleImages = [
  "https://cdn.cosmos.so/4b771c5c-d1eb-4948-b839-255dbeb931ba?format=jpeg",
  "https://cdn.cosmos.so/a8d82afd-2293-43ad-bac3-887683d85b44?format=jpeg",
  "https://cdn.cosmos.so/49206ba5-c174-4cd5-aee8-5b744842e6c2?format=jpeg",
  "https://cdn.cosmos.so/b29bd150-6477-420f-8efb-65ed99694421?format=jpeg",
  "https://cdn.cosmos.so/e1a0313e-7617-431d-b7f1-f1b169e6bcb4?format=jpeg",
  "https://cdn.cosmos.so/ad640c12-69fb-4186-bc3d-b1cc93986a37?format=jpeg",
  "https://cdn.cosmos.so/5cf0c3d2-e785-41a3-b0c8-a073ee2f2862?format=jpeg",
  "https://cdn.cosmos.so/938ab21c-a975-41b3-b303-418290343b09?format=jpeg",
  "https://cdn.cosmos.so/2e14a9bb-27e3-40fd-b940-cfb797a1224c?format=jpeg",
  "https://cdn.cosmos.so/81841d9f-e164-4770-aebc-cfc97d72f3ab?format=jpeg",
  "https://cdn.cosmos.so/49b81db0-37ea-4569-b0d6-04afa5115a10?format=jpeg",
  "https://cdn.cosmos.so/ade1834b-9317-44fb-8dc3-b43d29acd409?format=jpeg",
  "https://cdn.cosmos.so/621c250c-3833-45f9-862a-3f400aaf8f28?format=jpeg",
  "https://cdn.cosmos.so/f9b7eae8-e5a6-4ce6-b6e1-9ef125ba7f8e?format=jpeg",
  "https://cdn.cosmos.so/bd56ed6d-1bbd-44a4-b1a1-79b7199bbebb?format=jpeg",
]

const MarqueeItem = ({ children }: { children: React.ReactNode }) => (
  <div className="mx-2 sm:mx-3 md:mx-4 hover:scale-105 cursor-pointer duration-300 ease-in-out">
    {children}
  </div>
)

export default function SimpleMarqueeDemo() {
  const firstThird = exampleImages.slice(
    0,
    Math.floor(exampleImages.length / 3)
  )
  const secondThird = exampleImages.slice(
    Math.floor(exampleImages.length / 3),
    Math.floor((2 * exampleImages.length) / 3)
  )
  const lastThird = exampleImages.slice(
    Math.floor((2 * exampleImages.length) / 3)
  )

  const container = useRef<HTMLDivElement>(null)

  return (
    <div
      className="flex w-full h-full relative justify-center items-center flex-col bg-black overflow-auto"
      ref={container}
    >
      <h1 className="absolute text-center text-3xl sm:text-5xl md:text-6xl top-1/3 sm:top-1/3 md:top-1/4 text-white font-calendas">
        Weekly Finds
      </h1>
      <div className="absolute h-[170%] sm:h-[200%] top-0 w-full justify-center items-center flex flex-col space-y-2 sm:space-y-3 md:space-y-4">
        <SimpleMarquee
          className="w-full"
          baseVelocity={8}
          repeat={4}
          draggable={false}
          scrollSpringConfig={{ damping: 50, stiffness: 400 }}
          slowDownFactor={0.1}
          slowdownOnHover
          slowDownSpringConfig={{ damping: 60, stiffness: 300 }}
          scrollAwareDirection={true}
          scrollContainer={container}
          useScrollVelocity={true}
          direction="left"
        >
          {firstThird.map((src, i) => (
            <MarqueeItem key={i}>
              <img
                src={src}
                alt={`Image ${i + 1}`}
                className="h-20 w-32 sm:h-24 sm:w-40 md:h-32 md:w-48 object-cover"
              />
            </MarqueeItem>
          ))}
        </SimpleMarquee>

        <SimpleMarquee
          className="w-full"
          baseVelocity={8}
          repeat={4}
          scrollAwareDirection={true}
          scrollSpringConfig={{ damping: 50, stiffness: 400 }}
          slowdownOnHover
          slowDownFactor={0.1}
          slowDownSpringConfig={{ damping: 60, stiffness: 300 }}
          useScrollVelocity={true}
          scrollContainer={container}
          draggable={false}
          direction="right"
        >
          {secondThird.map((src, i) => (
            <MarqueeItem key={i}>
              <img
                src={src}
                alt={`Image ${i + firstThird.length}`}
                className="h-20 w-32 sm:h-24 sm:w-40 md:h-32 md:w-48 object-cover"
              />
            </MarqueeItem>
          ))}
        </SimpleMarquee>

        <SimpleMarquee
          className="w-full"
          baseVelocity={8}
          repeat={4}
          draggable={false}
          scrollSpringConfig={{ damping: 50, stiffness: 400 }}
          slowDownFactor={0.1}
          slowdownOnHover
          slowDownSpringConfig={{ damping: 60, stiffness: 300 }}
          scrollAwareDirection={true}
          scrollContainer={container}
          useScrollVelocity={true}
          direction="left"
        >
          {lastThird.map((src, i) => (
            <MarqueeItem key={i}>
              <img
                src={src}
                alt={`Image ${i + firstThird.length + secondThird.length}`}
                className="h-20 w-32 sm:h-24 sm:w-40 md:h-32 md:w-48 object-cover"
              />
            </MarqueeItem>
          ))}
        </SimpleMarquee>
      </div>
    </div>
  );
}
src/fancy/examples/blocks/simple-marquee-drag-demo.tsx
파일 저장

import React, { useRef } from "react"
import { motion } from "motion/react"

import SimpleMarquee from "@/fancy/components/blocks/simple-marquee"
import VerticalCutReveal from "@/fancy/components/text/vertical-cut-reveal"

const imgs = [
  "https://cdn.cosmos.so/97fd931c-28cc-480f-91a8-cffb635cf832?format=jpeg",
  "https://cdn.cosmos.so/305a25f2-cc53-4ff3-95a5-6a5ca1517ff8?format=jpeg",
  "https://cdn.cosmos.so/2a024234-6713-41b2-a2f2-1d5e385ac490?format=jpeg",
  "https://cdn.cosmos.so/89cc65c1-b0bf-42f6-9afc-4db6678ae652?format=jpeg",
  "https://cdn.cosmos.so/211e0ca7-4126-4222-9de8-03aeb1e4688e?format=jpeg",
  "https://cdn.cosmos.so/b7dc0ec1-4b03-42ce-9805-1964d0f49feb?format=jpeg",
  "https://cdn.cosmos.so/43be3f32-bd6e-4fd1-93c8-d54e0d8196ee?format=jpeg",
  "https://cdn.cosmos.so/d0d146aa-b49c-48be-8b09-6b7eaf8e836d?format=jpeg",
  "https://cdn.cosmos.so/e765d51f-8be7-4618-83e2-90c13379b366?format=jpeg",
  "https://cdn.cosmos.so/c1854fe0-e974-4cb6-8bc4-ffcf1686b9e7?format=jpeg",
]

const firstRow = imgs.slice(0, 5)
const secondRow = imgs.slice(5)

const MarqueeItem = ({
  children,
  index,
}: {
  children: React.ReactNode
  index: number
}) => (
  <motion.div
    className="mx-2 sm:mx-3 md:mx-4 border-neutral-600 p-2 sm:p-3 md:p-4 shadow shadow-white/20 flex justify-center items-center flex-col perspective-near transform-3d rotate-y-45 rotate-z-12 bg-black"
    initial={{ opacity: 0, y: 0, filter: "blur(10px)" }}
    animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
    transition={{ duration: 0.5, ease: "easeOut", delay: 0.3 + 0.1 * index }}
    style={{
      transform: `translateZ(-150px) rotate(${index * 15}deg)`,
      transformStyle: "preserve-3d",
    }}
  >
    {children}
  </motion.div>
)

export default function SimpleMarqueeDemo() {
  const container = useRef<HTMLDivElement>(null)

  return (
    <div
      className="flex w-full h-full relative justify-center items-center flex-col bg-black overflow-y-auto overflow-x-hidden"
      ref={container}
    >
      <h1 className="absolute text-center text-3xl sm:text-5xl md:text-6xl top-32 sm:top-1/4 text-white font-calendas">
        <VerticalCutReveal splitBy="characters" staggerDuration={0.04}>
          New Arrivals
        </VerticalCutReveal>
      </h1>
      <div className="absolute h-full top-1/5 sm:top-2/4 w-full justify-center items-center flex flex-col space-y-2 sm:space-y-3 md:space-y-4 z-0">
        <SimpleMarquee
          className="w-full z-10 relative"
          baseVelocity={8}
          repeat={2}
          draggable={true}
          dragSensitivity={0.08}
          useScrollVelocity={true}
          scrollAwareDirection={true}
          scrollSpringConfig={{ damping: 50, stiffness: 400 }}
          scrollContainer={container}
          dragAwareDirection={true}
          grabCursor
          direction="left"
        >
          {firstRow.map((src, i) => (
            <MarqueeItem key={i} index={i}>
              <motion.img
                src={src}
                alt={`Image ${i + 1}`}
                draggable={false}
                className="w-32 sm:w-36 md:w-44 select-none"
              />
            </MarqueeItem>
          ))}
        </SimpleMarquee>

        <SimpleMarquee
          className="w-full z-[100] relative"
          baseVelocity={8}
          repeat={2}
          draggable={true}
          dragSensitivity={0.08}
          useScrollVelocity={true}
          scrollAwareDirection={true}
          scrollSpringConfig={{ damping: 50, stiffness: 400 }}
          scrollContainer={container}
          dragAwareDirection={true}
          grabCursor
          direction="right"
        >
          {secondRow.map((src, i) => (
            <MarqueeItem key={i} index={i}>
              <motion.img
                src={src}
                alt={`Image ${i + 6}`}
                draggable={false}
                className="w-32 sm:w-36 md:w-44 select-none"
              />
            </MarqueeItem>
          ))}
        </SimpleMarquee>
      </div>
    </div>
  );
}
src/fancy/examples/blocks/simple-marquee-easing-demo.tsx
파일 저장

import React, { useRef } from "react"

import SimpleMarquee from "@/fancy/components/blocks/simple-marquee"

const exampleImages = [
  "https://cdn.cosmos.so/4b771c5c-d1eb-4948-b839-255dbeb931ba?format=jpeg",
  "https://cdn.cosmos.so/a8d82afd-2293-43ad-bac3-887683d85b44?format=jpeg",
  "https://cdn.cosmos.so/49206ba5-c174-4cd5-aee8-5b744842e6c2?format=jpeg",
  "https://cdn.cosmos.so/b29bd150-6477-420f-8efb-65ed99694421?format=jpeg",
  "https://cdn.cosmos.so/e1a0313e-7617-431d-b7f1-f1b169e6bcb4?format=jpeg",
  "https://cdn.cosmos.so/ad640c12-69fb-4186-bc3d-b1cc93986a37?format=jpeg",
  "https://cdn.cosmos.so/5cf0c3d2-e785-41a3-b0c8-a073ee2f2862?format=jpeg",
  "https://cdn.cosmos.so/938ab21c-a975-41b3-b303-418290343b09?format=jpeg",
  "https://cdn.cosmos.so/2e14a9bb-27e3-40fd-b940-cfb797a1224c?format=jpeg",
  "https://cdn.cosmos.so/81841d9f-e164-4770-aebc-cfc97d72f3ab?format=jpeg",
  "https://cdn.cosmos.so/49b81db0-37ea-4569-b0d6-04afa5115a10?format=jpeg",
  "https://cdn.cosmos.so/ade1834b-9317-44fb-8dc3-b43d29acd409?format=jpeg",
  "https://cdn.cosmos.so/621c250c-3833-45f9-862a-3f400aaf8f28?format=jpeg",
  "https://cdn.cosmos.so/f9b7eae8-e5a6-4ce6-b6e1-9ef125ba7f8e?format=jpeg",
  "https://cdn.cosmos.so/bd56ed6d-1bbd-44a4-b1a1-79b7199bbebb?format=jpeg",
]

const MarqueeItem = ({ children }: { children: React.ReactNode }) => (
  <div className="mb-4 hover:scale-105 cursor-pointer duration-300 ease-in-out rounded overflow-hidden">
    {children}
  </div>
)

export default function SimpleMarqueeDemo() {
  const firstThird = exampleImages.slice(
    0,
    Math.floor(exampleImages.length / 3)
  )
  const secondThird = exampleImages.slice(
    Math.floor(exampleImages.length / 3),
    Math.floor((2 * exampleImages.length) / 3)
  )
  const lastThird = exampleImages.slice(
    Math.floor((2 * exampleImages.length) / 3)
  )

  const containerRef = useRef<HTMLDivElement>(null)

  const easeFn = (x: number) => {
    return x === 0
      ? 0
      : x === 1
        ? 1
        : x < 0.5
          ? Math.pow(2, 20 * x - 10) / 2
          : (2 - Math.pow(2, -20 * x + 10)) / 2
  }

  return (
    <div
      className="flex w-full h-full relative justify-center items-center flex-col bg-black"
      ref={containerRef}
    >
      <div className="h-full top-0 w-full flex flex-row items-start">
        {/* Just fluff for the demo */}
        <div className="hidden sm:flex w-2/4 px-8 md:px-16 h-full items-center justify-center flex-col space-y-6 md:space-y-8 order-2">
          <h1 className="text-white font-calendas text-3xl md:text-4xl tracking-tight">
            Welcome Back!
          </h1>

          <div className="space-y-3 md:space-y-4 w-full max-w-[320px] md:max-w-[360px]">
            <div className="space-y-1">
              <input
                type="email"
                className="w-full bg-transparent border border-white rounded px-3.5 md:px-4 py-2 text-base md:text-lg text-white font-overusedGrotesk focus:outline-none focus:border-white"
                placeholder="Email"
              />
            </div>

            <div className="space-y-1">
              <input
                type="password"
                className="w-full bg-transparent border border-white rounded px-3.5 md:px-4 py-2 text-base md:text-lg text-white font-overusedGrotesk focus:outline-none focus:border-white"
                placeholder="Password"
              />
            </div>

            <button className="w-full bg-white text-black font-overusedGrotesk font-medium py-2 text-base md:text-lg rounded hover:bg-neutral-200 transition-colors">
              Sign In
            </button>

            <p className="text-neutral-400 text-sm md:text-base text-center">
              Don't have an account?{" "}
              <a href="#" className="text-white hover:text-neutral-200">
                Sign up
              </a>
            </p>
          </div>
        </div>

        {/* Marquee section - this is the main content */}
        <div className="w-full sm:w-2/4 h-full flex flex-row space-x-2 sm:space-x-3 md:space-x-4 px-2 sm:px-3 md:px-4 justify-center sm:justify-end items-center sm:-mt-8 md:-mt-10 order-1">
          <SimpleMarquee
            className="h-full"
            baseVelocity={25}
            repeat={4}
            easing={easeFn}
            direction="up"
          >
            {firstThird.map((src, i) => (
              <MarqueeItem key={i}>
                <img
                  src={src}
                  alt={`Image ${i + 1}`}
                  draggable={false}
                  className="w-24 sm:w-28 md:w-32 object-cover"
                />
              </MarqueeItem>
            ))}
          </SimpleMarquee>

          <SimpleMarquee
            className="h-full"
            baseVelocity={25}
            repeat={4}
            easing={easeFn}
            direction="down"
          >
            {secondThird.map((src, i) => (
              <MarqueeItem key={i}>
                <img
                  src={src}
                  draggable={false}
                  alt={`Image ${i + firstThird.length}`}
                  className="w-24 sm:w-28 md:w-32 object-cover"
                />
              </MarqueeItem>
            ))}
          </SimpleMarquee>

          <SimpleMarquee
            className="h-full"
            baseVelocity={25}
            repeat={4}
            easing={easeFn}
            direction="up"
          >
            {lastThird.map((src, i) => (
              <MarqueeItem key={i}>
                <img
                  src={src}
                  draggable={false}
                  alt={`Image ${i + firstThird.length + secondThird.length}`}
                  className="w-24 sm:w-28 md:w-32 object-cover"
                />
              </MarqueeItem>
            ))}
          </SimpleMarquee>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/blocks/simple-marquee-explainer-demo.tsx
파일 저장

import { useState } from "react"

import { Button } from "@/components/ui/button"
import SimpleMarquee from "@/fancy/components/blocks/simple-marquee"

const MarqueeItem = ({ index }: { index: number }) => (
  <div className="bg-zinc-950 text-white w-24 h-24 sm:w-40 sm:h-40 md:w-48 md:h-48 mx-4 sm:mx-6 md:mx-8 relative rounded-xl shadow  shadow-white">
    <span className="absolute top-2 left-3 sm:left-3 md:left-4 text-base sm:text-lg md:text-lg">
      ITEM {index.toString().padStart(2, "0")}
    </span>
    <span className="absolute bottom-2 left-3 sm:left-3 md:left-4 text-sm sm:text-base md:text-base opacity-70">
      fancy
    </span>
  </div>
)

export default function SimpleMarqueeExplainerDemo() {
  const [repeat, setRepeat] = useState(1)

  return (
    <div className="w-full h-full relative flex justify-center items-center bg-black">
      <Button
        variant={"outline"}
        size={"sm"}
        className="absolute top-4 left-4 h-8"
        onClick={() => setRepeat((prev) => (prev < 5 ? prev + 1 : 1))}
      >
        Repeat: {repeat}
      </Button>

      <div className="sm:m-6 md:m-8 border border-border p-4 flex justify-center items-center w-[200px] sm:w-[450px] md:w-[600px] h-[200px] sm:h-[300px] md:h-[400px]">
        <SimpleMarquee baseVelocity={30} repeat={repeat} direction="left">
          <MarqueeItem index={1} />
          <MarqueeItem index={2} />
        </SimpleMarquee>
      </div>
    </div>
  )
}
src/fancy/examples/blocks/stacking-cards-demo.tsx
파일 저장

// author: Khoa Phan <https://www.pldkhoa.dev>

"use client"

import { useRef } from "react"
import Image from "next/image"

import { cn } from "@/lib/utils"
import StackingCards, {
  StackingCardItem,
} from "@/fancy/components/blocks/stacking-cards"

const cards = [
  {
    bgColor: "bg-primary-orange",
    title: "The Guiding Light",
    description:
      "Lighthouses have stood as beacons of hope for centuries, guiding sailors safely through treacherous waters. Their glowing light and towering presence serve as a reminder of humanity’s connection to the sea.",
    image:
      "https://plus.unsplash.com/premium_vector-1739262161806-d954eb02427c?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxjb2xsZWN0aW9uLXBhZ2V8MXxxdGU5Smx2R3d0b3x8ZW58MHx8fHx8",
  },
  {
    bgColor: "bg-primary-blue",
    title: "Life Beneath the Waves",
    description:
      "From shimmering schools of fish to solitary hunters, the ocean is home to an incredible variety of marine life. Each species plays a vital role in maintaining the balance of underwater ecosystems.",
    image:
      "https://plus.unsplash.com/premium_vector-1739200616200-69a138d91627?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxjb2xsZWN0aW9uLXBhZ2V8MnxxdGU5Smx2R3d0b3x8ZW58MHx8fHx8",
  },
  {
    bgColor: "bg-primary-red",
    title: "Alone on the Open Sea",
    description:
      "Drifting across the endless horizon, traveling alone on the sea is a test of courage and resilience. With nothing but the waves and the sky, solitude becomes both a challenge and a source of deep reflection.",
    image:
      "https://plus.unsplash.com/premium_vector-1738597190290-a3b571590b9e?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxjb2xsZWN0aW9uLXBhZ2V8OHxxdGU5Smx2R3d0b3x8ZW58MHx8fHx8",
  },
  {
    bgColor: "bg-teal",
    title: "The Art of Sailing",
    description:
      "Harnessing the power of the wind, sailing is both a skill and an adventure. Whether racing across the waves or leisurely cruising, it’s a timeless way to explore the vast blue expanse.",
    image:
      "https://plus.unsplash.com/premium_vector-1738935247245-97940c74cced?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxjb2xsZWN0aW9uLXBhZ2V8MTZ8cXRlOUpsdkd3dG98fGVufDB8fHx8fA%3D%3D",
  },
  {
    bgColor: "bg-primary-blue",
    title: "The Era of Whaling",
    description:
      "Once a thriving industry, whale hunting shaped economies and cultures across the world. Today, efforts to protect these majestic creatures highlight the shift toward conservation and respect for marine life.",
    image:
      "https://plus.unsplash.com/premium_vector-1738935247692-1c2f2c924fd8?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxjb2xsZWN0aW9uLXBhZ2V8MjJ8cXRlOUpsdkd3dG98fGVufDB8fHx8fA%3D%3D",
  },
]

export default function StackingCardsDemo() {
  const container = useRef<HTMLDivElement>(null)

  return (
    <div
      className="h-[620px] bg-white overflow-auto text-white"
      ref={container}
    >
      <StackingCards
        totalCards={cards.length}
        scrollOptions={{ container: container }}
      >
        <div className="relative font-calendas h-[620px] w-full z-10 text-2xl md:text-7xl font-bold uppercase flex justify-center items-center text-primary-red whitespace-pre">
          Scroll down ↓
        </div>
        {cards.map(({ bgColor, description, image, title }, index) => {
          return (
            <StackingCardItem key={index} index={index} className="h-[620px]">
              <div
                className={cn(
                  bgColor,
                  "h-[80%] sm:h-[70%] flex-col sm:flex-row aspect-video px-8 py-10 flex w-11/12 rounded-3xl mx-auto relative"
                )}
              >
                <div className="flex-1 flex flex-col justify-center">
                  <h3 className="font-bold text-2xl mb-5">{title}</h3>
                  <p>{description}</p>
                </div>

                <div className="w-full sm:w-1/2 rounded-xl aspect-video relative overflow-hidden">
                  <Image
                    src={image}
                    alt={title}
                    className="object-cover"
                    fill
                  />
                </div>
              </div>
            </StackingCardItem>
          )
        })}

        <div className="w-full h-80 relative overflow-hidden">
          <h2 className="absolute bottom-0 left-0 translate-y-1/3 sm:text-[192px] text-[80px] text-primary-red font-calendas">
            fancy
          </h2>
        </div>
      </StackingCards>
    </div>
  );
}
src/fancy/examples/blocks/sticky-footer-demo.tsx
파일 저장

import React from "react"

const Preview: React.FC = () => {
  return (
    <div className="w-full bg-[#efefef] items-center justify-center h-full overflow-auto">
      {/* add relative positioning to the main conent */}
      <div className="relative h-full w-full z-10 text-2xl md:text-7xl font-bold uppercase flex justify-center items-center bg-primary-red text-white whitespace-pre">
        Scroll down ↓
      </div>

      {/* Sticky footer. The only important thing here is the z-index, the sticky position and the bottom value */}
      <div className="sticky z-0 bottom-0 left-0 w-full h-80 bg-white flex justify-center items-center">
        <div className="relative overflow-hidden w-full h-full flex justify-end px-12 text-right items-start py-12 text-primary-red">
          <div className="flex flex-row space-x-12 sm:pace-x-16  md:space-x-24 text-sm sm:text-lg md:text-xl">
            <ul>
              <li className="hover:underline cursor-pointer">Home</li>
              <li className="hover:underline cursor-pointer">Docs</li>
              <li className="hover:underline cursor-pointer">Comps</li>
            </ul>
            <ul>
              <li className="hover:underline cursor-pointer">Github</li>
              <li className="hover:underline cursor-pointer">Instagram</li>
              <li className="hover:underline cursor-pointer">X (Twitter)</li>
            </ul>
          </div>
          <h2 className="absolute bottom-0 left-0  translate-y-1/3 sm:text-[192px]  text-[80px] text-primary-red font-calendas">
            fancy
          </h2>
        </div>
      </div>
    </div>
  )
}

export default Preview
src/fancy/examples/carousel/box-carousel-autoplay-demo.tsx
파일 저장

"use client"

import { useRef, useState, useEffect } from "react"
import { Bug, BugOff } from "lucide-react"
import BoxCarousel, { 
  type BoxCarouselRef, 
  type CarouselItem, 
} from "@/fancy/components/carousel/box-carousel"
import useScreenSize from "@/hooks/use-screen-size"

// Sample carousel items with mix of images and videos
const carouselItems: CarouselItem[] = [
  {
    id: "1",
    type: "image",
    src: "https://cdn.cosmos.so/778d0640-d4b8-45b4-8bbe-862e759c231d?format=jpeg",
    alt: "Blurry poster"
  },
  {
    id: "2", 
    type: "image",
    src: "https://cdn.cosmos.so/27ac2696-1f2b-498e-8d3d-11f2dd358ab9?format=jpeg",
    alt: "Abstract blurry figure"
  },
  {
    id: "3",
    type: "image", 
    src: "https://cdn.cosmos.so/c48b739d-202d-4340-ab6b-afa34f0d7142?format=jpeg",
    alt: "Long exposure photo of a person"
  },
  {
    id: "4",
    type: "image",
    src: "https://cdn.cosmos.so/5332f9ac-7823-4635-871d-d4b3032e1c62?format=jpeg", 
    alt: "Blurry portrait photo of a person"
  },
  {
    id: "5",
    type: "image",
    src: "https://cdn.cosmos.so/d9ed937e-7c3b-4f64-a4f3-708d639f13a1?format=jpeg",
    alt: "Long exposure shots with multiple people"
  },
  {
    id: "6",
    type: "image",
    src: "https://cdn.cosmos.so/33b43e2a-da66-42d9-a0b1-08165d80b0aa?format=jpeg",
    alt: "Close up blurry photo of a person poster"
  },
  {
    id: "7",
    type: "image",
    src: "https://cdn.cosmos.so/40342df7-2ea2-4297-add2-fe17cdc62551?format=jpeg",
    alt: "Long exposure shot of a motorcyclist"
  },
]

export default function BoxCarouselDemo() {
  const carouselRef = useRef<BoxCarouselRef>(null)
  const [debug, setDebug] = useState(false)

  const screenSize = useScreenSize()

  // Responsive dimensions based on screen size
  const getCarouselDimensions = () => {
    if (screenSize.lessThan("md")) {
      return { width: 200, height: 150 }
    }
    return { width: 350, height: 250 }
  }

  const { width, height } = getCarouselDimensions()

  const handleIndexChange = (index: number) => {
    console.log('Index changed:', index)
  }

  const toggleDebug = () => {
    setDebug(!debug)
  }

  return (
    <div className="w-full max-w-4xl h-full p-6 flex justify-items-center justify-center items-center text-muted-foreground bg-[#fefefe]">
      <button
        onClick={toggleDebug}
        className="absolute top-4 left-4 p-1.5 border border-black text-black rounded-full cursor-pointer transition-all duration-300 ease-out hover:bg-gray-100 active:scale-95"
        title={debug ? "Debug Mode: ON" : "Debug Mode: OFF"}
      >
        {debug ? (
          <Bug size={10} />
        ) : (
          <BugOff size={10} />
        )}
      </button>

      <div className="space-y-24">

        <div className="flex justify-center">
          <BoxCarousel
            ref={carouselRef}
            items={carouselItems}
            width={width}
            height={height}
            direction="top"
            autoPlay
            autoPlayInterval={1500}
            onIndexChange={handleIndexChange}
            debug={debug}
            enableDrag
            perspective={1000}
          />
        </div>

      </div>
    </div>
  )
}
src/fancy/examples/carousel/box-carousel-demo.tsx
파일 저장

"use client"

import { useRef, useState  } from "react"
import { Bug, BugOff } from "lucide-react"
import BoxCarousel, { 
  type BoxCarouselRef, 
  type CarouselItem, 
} from "@/fancy/components/carousel/box-carousel"
import useScreenSize from "@/hooks/use-screen-size"

// Sample carousel items with mix of images and videos
const carouselItems: CarouselItem[] = [
  {
    id: "1",
    type: "image",
    src: "https://cdn.cosmos.so/778d0640-d4b8-45b4-8bbe-862e759c231d?format=jpeg",
    alt: "Blurry poster"
  },
  {
    id: "2", 
    type: "image",
    src: "https://cdn.cosmos.so/27ac2696-1f2b-498e-8d3d-11f2dd358ab9?format=jpeg",
    alt: "Abstract blurry figure"
  },
  {
    id: "3",
    type: "image", 
    src: "https://cdn.cosmos.so/c48b739d-202d-4340-ab6b-afa34f0d7142?format=jpeg",
    alt: "Long exposure photo of a person"
  },
  {
    id: "4",
    type: "image",
    src: "https://cdn.cosmos.so/5332f9ac-7823-4635-871d-d4b3032e1c62?format=jpeg", 
    alt: "Blurry portrait photo of a person"
  },
  {
    id: "5",
    type: "image",
    src: "https://cdn.cosmos.so/d9ed937e-7c3b-4f64-a4f3-708d639f13a1?format=jpeg",
    alt: "Long exposure shots with multiple people"
  },
  {
    id: "6",
    type: "image",
    src: "https://cdn.cosmos.so/33b43e2a-da66-42d9-a0b1-08165d80b0aa?format=jpeg",
    alt: "Close up blurry photo of a person poster"
  },
  {
    id: "7",
    type: "image",
    src: "https://cdn.cosmos.so/40342df7-2ea2-4297-add2-fe17cdc62551?format=jpeg",
    alt: "Long exposure shot of a motorcyclist"
  },
]

export default function BoxCarouselDemo() {
  const carouselRef = useRef<BoxCarouselRef>(null)
  const [debug, setDebug] = useState(false)
  const screenSize = useScreenSize()

  // Responsive dimensions based on screen size
  const getCarouselDimensions = () => {
    if (screenSize.lessThan("md")) {
      return { width: 200, height: 150 }
    }
    return { width: 350, height: 250 }
  }

  const { width, height } = getCarouselDimensions()

  const handleNext = () => {
    carouselRef.current?.next()
  }

  const handlePrev = () => {
    carouselRef.current?.prev()
  }

  const handleIndexChange = (index: number) => {
    console.log('Index changed:', index)
  }

  const toggleDebug = () => {
    setDebug(!debug)
  }

  return (
    <div className="w-full max-w-4xl h-full p-6 flex justify-items-center justify-center items-center text-muted-foreground bg-[#fefefe]">
      <button
        onClick={toggleDebug}
        className="absolute top-4 left-4 p-1.5 border border-black text-black rounded-full cursor-pointer transition-all duration-300 ease-out hover:bg-gray-100 active:scale-95"
        title={debug ? "Debug Mode: ON" : "Debug Mode: OFF"}
      >
        {debug ? (
          <Bug size={10} />
        ) : (
          <BugOff size={10} />
        )}
      </button>

      <div className="space-y-24">

        <div className="flex justify-center pt-20">
          <BoxCarousel
            ref={carouselRef}
            items={carouselItems}
            width={width}
            height={height}
            direction="right"
            onIndexChange={handleIndexChange}
            debug={debug}
            enableDrag
            perspective={1000}
          />
        </div>


        <div className="flex gap-2 justify-center">
          <button 
            onClick={handlePrev}
            className="px-2 py-0.5 text-xs border border-black text-black rounded-full cursor-pointer transition-all duration-300 ease-out hover:bg-gray-100 active:scale-95"
          >
            Prev
          </button>
          <button 
            onClick={handleNext}
            className="px-2 py-0.5 text-xs border border-black text-black rounded-full cursor-pointer transition-all duration-300 ease-out hover:bg-gray-100 active:scale-95"
          >
            Next
          </button>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/carousel/box-carousel-video-demo.tsx
파일 저장

"use client"

import { useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"

import BoxCarousel, {
  type BoxCarouselRef,
  type CarouselItem,
} from "@/fancy/components/carousel/box-carousel"
import useScreenSize from "@/hooks/use-screen-size"

// Sample carousel items with mix of images and videos
const carouselItems: CarouselItem[] = [
  {
    id: "1",
    type: "video",
    src: "https://cdn.cosmos.so/3fe9c8a8-b562-4090-a5ac-5bbdf655a938.mp4",
    alt: "@portalsandpaths",
  },
  {
    id: "2",
    type: "video",
    src: "https://cdn.cosmos.so/594e87df-e8ca-4d03-8137-f78b5dab6793.mp4",
    alt: "@studio.size",
  },
  {
    id: "3",
    type: "image",
    src: "https://cdn.cosmos.so/92162e2e-abf6-4b98-a557-bfe25a336608?format=jpeg",
    alt: "@david_wise",
  },
  {
    id: "4",
    type: "video",
    src: "https://cdn.cosmos.so/aae0a1e6-d03d-43ae-aa7f-21627a950730.mp4",
    alt: "@svenvranjes",
  },
  {
    id: "5",
    type: "image",
    src: "https://cdn.cosmos.so/7a5f1a73-ca73-466b-afaf-2b3d0639aa1a?format=jpeg",
    alt: "@deconstructie",
  },
]

export default function BoxCarouselDemo() {
  const carouselRef = useRef<BoxCarouselRef>(null)
  const [currentIndex, setCurrentIndex] = useState(0)
  const screenSize = useScreenSize()

  // Responsive dimensions based on screen size
  const getCarouselDimensions = () => {
    if (screenSize.lessThan("md")) {
      return { width: 200, height: 150 }
    }
    return { width: 350, height: 250 }
  }

  const { width, height } = getCarouselDimensions()

  const handleIndexChange = (index: number) => {
    setCurrentIndex(index)
  }

  return (
    <div className="w-full max-w-4xl h-full p-6 flex justify-items-center justify-center items-center text-muted-foreground bg-[#fefefe]">


      <div className="space-y-20">
        <div className="flex justify-center">
          <BoxCarousel
            ref={carouselRef}
            items={carouselItems}
            width={width}
            height={height}
            direction="right"
            onIndexChange={handleIndexChange}
            enableDrag
            perspective={1000}
          />
        </div>

        <div className="flex gap-2 justify-center w-full items-start">
          <AnimatePresence mode="popLayout">
            <motion.span
              key={currentIndex}
              layoutId="id"
              layout
              initial={{ opacity: 0, y: 0 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: 0 }}
              transition={{ duration: 0.2, ease: "easeOut" }}
              className="px-3 py-1.5 text-sm w-autp bg-gray-200 text-black rounded-xl"
            >
              {carouselItems[currentIndex]?.alt}
            </motion.span>
          </AnimatePresence>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/filter/gooey-svg-filter-demo.tsx
파일 저장

import { useState } from "react"
import { AnimatePresence, motion } from "motion/react"

import useDetectBrowser from "@/hooks/use-detect-browser"
import useScreenSize from "@/hooks/use-screen-size"
import { Button } from "@/components/ui/button"
import GooeySvgFilter from "@/fancy/components/filter/gooey-svg-filter"

const TAB_CONTENT = [
  {
    title: "2024",
    files: [
      "learning-to-meditate.md",
      "spring-garden-plans.md",
      "travel-wishlist.md",
      "new-coding-projects.md",
    ],
  },
  {
    title: "2023",
    files: [
      "year-in-review.md",
      "marathon-training-log.md",
      "recipe-collection.md",
      "book-reflections.md",
    ],
  },
  {
    title: "2022",
    files: [
      "moving-to-a-new-city.md",
      "starting-a-blog.md",
      "photography-basics.md",
      "first-coding-project.md",
    ],
  },
  {
    title: "2021",
    files: [
      "goals-and-aspirations.md",
      "daily-gratitude.md",
      "learning-to-cook.md",
      "remote-work-journal.md",
    ],
  },
]

export default function GooeyDemo() {
  const [activeTab, setActiveTab] = useState(0)
  const [isGooeyEnabled, setIsGooeyEnabled] = useState(true)
  const screenSize = useScreenSize()
  const browserName = useDetectBrowser()
  const isSafari = browserName === "Safari"

  return (
    <div className="relative w-full h-full flex justify-center p-8 font-calendas md:text-base text-xs sm:text-sm bg-white dark:bg-black">
      <GooeySvgFilter
        id="gooey-filter"
        strength={screenSize.lessThan("md") ? 8 : 15}
      />

      <Button
        variant="outline"
        onClick={() => setIsGooeyEnabled(!isGooeyEnabled)}
        className="absolute top-4 left-4 font-overused-grotesk"
      >
        {isGooeyEnabled ? "Disable filter" : "Enable filter"}
      </Button>

      <div className="w-11/12 md:w-4/5 relative mt-24">
        <div
          className="absolute inset-0"
          style={{ filter: isGooeyEnabled ? "url(#gooey-filter)" : "none" }}
        >
          <div className="flex w-full ">
            {TAB_CONTENT.map((_, index) => (
              <div key={index} className="relative flex-1 h-8 md:h-12">
                {activeTab === index && (
                  <motion.div
                    layoutId="active-tab"
                    className="absolute inset-0 bg-[#efefef]"
                    transition={{
                      type: "spring",
                      bounce: 0.0,
                      duration: isSafari ? 0 : 0.4,
                    }}
                  />
                )}
              </div>
            ))}
          </div>
          {/* Content panel */}
          <div className="w-full h-[200px] sm:h-[250px] md:h-[300px] bg-[#efefef] overflow-hidden text-muted-foreground">
            <AnimatePresence mode="popLayout">
              <motion.div
                key={activeTab}
                initial={{
                  opacity: 0,
                  y: 50,
                  filter: "blur(10px)",
                }}
                animate={{
                  opacity: 1,
                  y: 0,
                  filter: "blur(0px)",
                }}
                exit={{
                  opacity: 0,
                  y: -50,
                  filter: "blur(10px)",
                }}
                transition={{
                  duration: 0.2,
                  ease: "easeOut",
                }}
                className="p-8 md:p-12"
              >
                <div className="space-y-2 mt-4 sm:mt-8 md:mt-8">
                  <ul className="">
                    {TAB_CONTENT[activeTab].files.map((file, index) => (
                      <li
                        key={file}
                        className="border-b border-muted-foreground/50 pt-2 pb-1 text-black"
                      >
                        {file}
                      </li>
                    ))}
                  </ul>
                </div>
              </motion.div>
            </AnimatePresence>
          </div>
        </div>

        {/* Interactive text overlay, no filter */}
        <div className="relative flex w-full ">
          {TAB_CONTENT.map((tab, index) => (
            <button
              key={index}
              onClick={() => setActiveTab(index)}
              className="flex-1 h-8 md:h-12"
            >
              <span
                className={`
                w-full h-full flex items-center justify-center
                ${activeTab === index ? "text-black" : "text-muted-foreground"}
              `}
              >
                {tab.title}
              </span>
            </button>
          ))}
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/filter/gooey-svg-filter-menu-demo.tsx
파일 저장

import { useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Home, Mail, Menu, Settings, User, X } from "lucide-react"

import useDetectBrowser from "@/hooks/use-detect-browser"
import GooeySvgFilter from "@/fancy/components/filter/gooey-svg-filter"

const MENU_ITEMS = [
  { icon: Home, label: "Home" },
  { icon: Mail, label: "Contact" },
  { icon: User, label: "Profile" },
  { icon: Settings, label: "Settings" },
]

export default function GooeyDemo() {
  const [isOpen, setIsOpen] = useState(false)
  const browser = useDetectBrowser()
  const isSafari = browser === "Safari"

  return (
    <div className="relative w-full h-full flex items-center justify-center dark:bg-black bg-white">
      <GooeySvgFilter id="gooey-filter-menu" strength={5} />

      <div
        className="absolute top-4 left-4"
        style={{ filter: "url(#gooey-filter-menu)" }}
      >
        {/* Menu Items */}
        <AnimatePresence>
          {isOpen &&
            MENU_ITEMS.map((item, index) => {
              const Icon = item.icon
              return (
                <motion.button
                  key={item.label}
                  className="absolute w-12 h-12 bg-[#efefef] rounded-full flex items-center justify-center"
                  initial={{ x: 0, opacity: 0 }}
                  animate={{
                    y: (index + 1) * 44,
                    opacity: 1,
                  }}
                  exit={{
                    y: 0,
                    opacity: 0,
                    transition: {
                      delay:
                        (MENU_ITEMS.length - index) * (isSafari ? 0.0 : 0.05),
                      duration: isSafari ? 0 : 0.4,
                      type: "spring",
                      bounce: 0,
                    },
                  }}
                  transition={{
                    delay: index * (isSafari ? 0.0 : 0.05),
                    duration: isSafari ? 0 : 0.4,
                    type: "spring",
                    bounce: 0,
                  }}
                >
                  <AnimatePresence mode="wait">
                    <motion.div
                      key={item.label}
                      initial={{ opacity: 0, filter: "blur(10px)" }}
                      animate={{ opacity: 1, filter: "blur(0px)" }}
                      exit={{ opacity: 0, filter: "blur(10px)" }}
                      transition={{
                        delay: index * (isSafari ? 0.0 : 0.05),
                        duration: isSafari ? 0 : 0.2,
                        type: "spring",
                        bounce: 0,
                      }}
                    >
                      <Icon className="w-5 h-5 text-muted-foreground hover:text-black" />
                    </motion.div>
                  </AnimatePresence>
                </motion.button>
              )
            })}
        </AnimatePresence>

        {/* Main Menu Button */}
        <motion.button
          className="relative w-12 h-12 bg-[#efefef] rounded-full flex items-center justify-center"
          onClick={() => setIsOpen(!isOpen)}
        >
          <AnimatePresence mode="wait">
            {isOpen ? (
              <motion.div
                key="close"
                initial={{ opacity: 0, filter: "blur(10px)" }}
                animate={{ opacity: 1, filter: "blur(0px)" }}
                exit={{ opacity: 0, filter: "blur(10px)" }}
                transition={{ duration: isSafari ? 0 : 0.2 }}
              >
                <X className="w-5 h-5 text-black" />
              </motion.div>
            ) : (
              <motion.div
                key="menu"
                initial={{ opacity: 0, filter: "blur(10px)" }}
                animate={{ opacity: 1, filter: "blur(0px)" }}
                exit={{ opacity: 0, filter: "blur(10px)" }}
                transition={{ duration: isSafari ? 0 : 0.2 }}
              >
                <Menu className="w-5 h-5 text-black" />
              </motion.div>
            )}
          </AnimatePresence>
        </motion.button>
      </div>

      <p>Open the menu in the top left corner</p>
    </div>
  )
}
src/fancy/examples/filter/gooey-svg-filter-pixel-trail-demo.tsx
파일 저장

import useDetectBrowser from "@/hooks/use-detect-browser"
import useScreenSize from "@/hooks/use-screen-size"
import PixelTrail from "@/fancy/components/background/pixel-trail"
import GooeySvgFilter from "@/fancy/components/filter/gooey-svg-filter"

export default function GooeyDemo() {
  const screenSize = useScreenSize()
  const browserName = useDetectBrowser()
  const isSafari = browserName === "Safari"

  return (
    <div className="relative w-full h-full flex flex-col items-center justify-center gap-8 bg-black text-center text-pretty">
      <img
        src="https://images.aiscribbles.com/34fe5695dbc942628e3cad9744e8ae13.png?v=60d084"
        alt="impressionist painting"
        className="w-full h-full object-cover absolute inset-0 opacity-70"
      />

      <GooeySvgFilter id="gooey-filter-pixel-trail" strength={5} />

      <div
        className="absolute inset-0 z-0"
        style={{ filter: isSafari ? "none" : "url(#gooey-filter-pixel-trail)" }}
      >
        <PixelTrail
          pixelSize={screenSize.lessThan(`md`) ? 24 : 32}
          fadeDuration={0}
          delay={500}
          pixelClassName="bg-white"
        />
      </div>

      <p className="text-white text-4xl sm:text-5xl md:text-7xl z-10 font-calendas w-1/2 font-bold">
        Speaking things into existence
        <span className="font-overused-grotesk"></span>
      </p>
    </div>
  )
}
src/fancy/examples/filter/pixelate-svg-filter-demo.tsx
파일 저장

import { useRef } from "react"
import PixelateSvgFilter from "@/fancy/components/filter/pixelate-svg-filter"
import { useMousePosition } from "@/hooks/use-mouse-position"

export default function PixelateSVGFilterDemo() {
    const containerRef = useRef<HTMLDivElement>(null)
    const mousePosition = useMousePosition(containerRef)
    const pixelSize = Math.min(Math.max(mousePosition.x / 30, 1), 64)

  return (
    <div className="relative flex flex-col items-center justify-center w-full h-full gap-4 bg-black" ref={containerRef}>
      <PixelateSvgFilter id="pixelate-filter" size={pixelSize} crossLayers />
      <div 
        id="image-container"
        className="w-1/2 md:w-1/3 h-1/2 overflow-hidden relative text-white"
        style={{ filter: "url(#pixelate-filter)" }}
      >
        <video
          src={"https://cdn.cosmos.so/96ae0b34-289d-489d-94a1-c68925ddd3a9.mp4"}
          className="w-full h-full object-cover absolute inset-0"
          autoPlay
          muted
          playsInline
          loop
          //style={{ filter: "url(#pixelate-filter)" }}

        />
      </div>
      
    </div>
  )
}
src/fancy/examples/filter/pixelate-svg-filter-text.tsx
파일 저장

import { useEffect, useRef, useState } from "react"
import { animate, useMotionValue, useMotionValueEvent } from "motion/react"

import PixelateSvgFilter from "@/fancy/components/filter/pixelate-svg-filter"

export default function PixelateSVGFilterDemo() {
  const containerRef = useRef<HTMLDivElement>(null)
  const pixelSize = useMotionValue(16)
  const [size, setSize] = useState(16)
  const [isAnimating, setIsAnimating] = useState(true)

  useMotionValueEvent(pixelSize, "change", (latest) => {
    setSize(latest)
  })

  useEffect(() => {
    const controls = animate(pixelSize, 1, {
      duration: 1.2,
      ease: "easeOut",
      onComplete: () => setIsAnimating(false),
    })

    return controls.stop
  }, [])

  return (
    <div
      className="relative flex flex-col md:flex-row w-full h-full bg-background p-4 sm:p-8 md:p-12"
      ref={containerRef}
    >
      {isAnimating && (
        <PixelateSvgFilter id="pixelate-text-filter" size={size} />
      )}

      {/* Left Content */}
      <div
        className="flex-1 mb-8 md:mb-0"
        style={{
          filter: isAnimating ? "url(#pixelate-text-filter)" : undefined,
        }}
      >
        <div className="mb-4 sm:mb-6">
          <h1 className="text-lg sm:text-xl mb-1">Ari — Yu</h1>
          <a
            href="mailto:hello@arianexus.io"
            className="text-muted-foreground text-xs sm:text-sm"
          >
            hello@ariyu.co
          </a>
        </div>

        <div className="mb-12 sm:mb-16 md:mb-24">
          <h2 className="text-base sm:text-lg font-bold">Creative Director</h2>
          <h2 className="text-base sm:text-lg font-bold">& Writer</h2>
        </div>

        <div className="w-full md:w-1/2">
          <h3 className="text-sm sm:text-base font-medium mb-2">
            Selected Works
          </h3>
          <p className="text-[10px] sm:text-xs text-muted-foreground">
            Dreamweaver #93 Dec 2023 Starlight #87 "digital dreams" The Quantum
            Mirror Press Holograph Vol.5 crystal edition 2020 Byte Flow Vol.12
            Neural Canvas #9 Synthmagazin 11/2020 VOID 2020 zine Nebula #4 VOID
            量子 + Wave zines Binary Pulse volume 7 Cyber Cascade
            (self-published)
          </p>
        </div>
      </div>

      {/* Right Content - Image */}
      <div className="hidden md:block md:w-36 md:h-36 relative mx-auto md:mr-12">
        <img
          className="w-full h-full object-cover absolute inset-0"
          src={
            "https://images.unsplash.com/photo-1729009704569-474ddd86ed3a?q=80&w=2043&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
          }
          style={{
            filter: isAnimating ? "url(#pixelate-text-filter)" : undefined,
          }}
        />
      </div>
    </div>
  )
}
src/fancy/examples/image/image-trail-demo.tsx
파일 저장

import Image from "next/image"
import { exampleImages } from "@/utils/demo-images"

import ImageTrail,{
  ImageTrailItem,
} from "@/fancy/components/image/image-trail"

const ImageTrailDemo = () => {
  return (
    <div className="w-full h-full bg-white relative text-foreground dark:text-muted">
      <ImageTrail
         threshold={80}
         keyframes={{ opacity: [0, 1, 1, 0], scale: [1, 1, 2] }}
         keyframesOptions={{
           opacity: { duration: 2, times: [0, 0.001, 0.9, 1] },
           scale: { duration: 2, times: [0, 0.8, 1] },
         }}
         repeatChildren={1}
      >
        {[...exampleImages, ...exampleImages].map((image, index) => (
          <ImageTrailItem key={index}>
            <div className="h-20 w-20 sm:w-28 sm:h-24 relative overflow-hidden">
              <Image
                src={image.url}
                alt="image"
                fill
                className="object-cover"
                sizes="96px"
              />
            </div>
          </ImageTrailItem>
        ))}
      </ImageTrail>
      <h1 className="text-5xl sm:text-9xl absolute top-1/2 left-1/2 pointer-events-none -translate-x-1/2 -translate-y-1/2 z-100">
        ALBUMS
      </h1>
    </div>
  )
}

export default ImageTrailDemo
src/fancy/examples/image/image-trail-instant-demo.tsx
파일 저장

import ImageTrail,{
  ImageTrailItem,
} from "@/fancy/components/image/image-trail"

const images = [
  "https://cdn.cosmos.so/7dc46d69-ad3b-4942-ab84-511ae786892e?format=jpeg",
  "https://cdn.cosmos.so/cb5c5995-4ba7-4519-a0e8-aa6427cc0a90?format=jpeg", 
  "https://cdn.cosmos.so/264d0ae9-f2e9-4deb-843c-229e70bbe4cc?format=jpeg",
  "https://cdn.cosmos.so/d9ed5da2-92b8-4b71-84e8-7bef645c44dc?format=jpeg",
  "https://cdn.cosmos.so/223c2fad-7dcb-46e0-9f00-826edcf8d7b1?format=jpeg",
  "https://cdn.cosmos.so/48e640ee-75e1-4390-a34a-b790785f033a?format=jpeg",
  "https://cdn.cosmos.so/d9c19f7c-d605-4257-8546-dafe0daa250f.?format=jpeg",
  "https://cdn.cosmos.so/5ff27be0-bed8-4779-b520-6896e68e7e4d?format=jpeg",
  "https://cdn.cosmos.so/9098d86e-b3f7-425f-be96-40df45c82342?format=jpeg",
  "https://cdn.cosmos.so/5c01be2f-57fe-4a1a-91ad-a37b9426e080?format=jpeg",
  "https://cdn.cosmos.so/6a1d9c63-5b32-4a22-b03e-5dc63164ad8a?format=jpeg",
]

const ImageTrailDemo = () => {
  return (
    <div className="w-full h-full bg-white relative text-foreground dark:text-muted">
      <ImageTrail
        threshold={100}
        intensity={1}
        keyframes={{ scale: [1, 1] }}
        keyframesOptions={{
          scale: { duration: 1, times: [1, 1] },
        }}
        repeatChildren={1}
      >
        {images.map((url, index) => (
          <ImageTrailItem key={index}>
            <div className="w-20 sm:w-28 h-full relative overflow-hidden">
              <img src={url} alt="image" className="object-cover" />
            </div>
          </ImageTrailItem>
        ))}
      </ImageTrail>
      <p className="text sm:text-lg absolute top-4 left-6 font-medium pointer-events-none z-100">
        move your cursor
      </p>
    </div>
  )
}

export default ImageTrailDemo
src/fancy/examples/image/image-trail-various-elements-demo.tsx
파일 저장

import Image from "next/image"

import ImageTrail,{
  ImageTrailItem,
} from "@/fancy/components/image/image-trail"

const ImageTrailVariousElementsDemo = () => {
  return (
    <div className="w-full h-full bg-white relative text-foreground dark:text-muted">
      <ImageTrail
        threshold={60}
        keyframes={{ opacity: [0, 1, 1, 0], scale: [1, 1, 0] }}
        keyframesOptions={{
          opacity: { duration: 1, times: [0, 0.001, 0.9, 1] },
          scale: { duration: 1, times: [0, 0.8, 1] },
        }}
      >
        <ImageTrailItem>
          <div className="w-20 sm:w-28 aspect-square relative overflow-hidden bg-orange-500">
            <Image
              src={
                "https://images.unsplash.com/photo-1727341554370-80e0fe9ad082?q=80&w=2276&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
              }
              alt=""
              fill
              className="object-cover"
            />
          </div>
        </ImageTrailItem>
        <ImageTrailItem>
          <div className="w-16 sm:w-20">
            <svg
              className="w-full h-full"
              viewBox="0 0 107 243"
              fill="none"
              xmlns="http://www.w3.org/2000/svg"
            >
              <path
                d="M90.6323 71.1433C95.9279 63.8693 101.283 56.5333 104.965 48.2748C105.355 47.1008 106.388 45.9327 106.181 44.6678C105.859 43.3526 103.93 43.0857 103.28 44.2887C100.187 51.4196 96.0129 56.3747 93.3873 59.7922C88.1477 66.6601 85.7113 69.8668 79.8443 76.9862C77.933 79.6185 75.3537 82.6433 72.6644 86.2504C73.4926 81.4036 74.2088 76.5375 74.7996 71.6559C74.9251 71.5843 75.0448 71.4992 75.1471 71.389C75.0911 71.4315 75.0545 71.4605 75.0178 71.4857C75.0583 71.4547 75.1066 71.418 75.1568 71.3793C75.2031 71.3503 75.3267 71.2594 75.4638 71.1433C75.7089 70.9751 75.8807 70.8552 75.9985 70.7681C76.2997 70.6115 76.5796 70.4007 76.8325 70.186C77.9947 69.4549 77.124 69.9752 79.2554 68.4163C80.7323 67.5769 89.335 58.7981 92.8661 54.05C96.8952 48.5765 99.7197 43.8168 101.515 38.4285C102.253 36.2623 102.907 34.0652 103.403 31.8313C103.538 30.903 104.054 29.8876 103.531 29.0056C102.811 27.6885 100.673 27.9554 100.316 29.4273C97.4184 40.5907 91.3679 49.4836 83.3965 57.7905C81.2478 60.1094 78.9407 62.2621 76.6124 64.3973C76.4097 64.6178 76.2186 64.8286 76.0371 65.0297C79.2419 52.6478 84.5298 40.9254 87.8581 28.5879C89.3543 22.923 90.1323 17.0995 90.5571 11.2624C90.6111 8.70172 91.1363 5.584 90.7501 3.12385C90.3756 2.0311 88.8002 1.74872 88.0222 2.58231C87.2364 3.45458 87.5627 4.76202 87.3697 5.8393C86.6534 15.6122 85.831 23.1241 82.7382 32.7017C80.9003 38.4304 78.765 44.1069 76.8287 49.8337C76.9522 47.4799 77.0353 45.1532 77.0565 42.9445C77.2804 37.4982 77.319 32.0421 76.5159 26.6364C75.7205 20.0161 74.093 13.5253 72.0929 7.17381C71.3091 4.73301 70.9713 3.853 70.396 2.39277C69.8824 1.37158 70.477 1.6733 69.2434 0.630829C68.1275 -0.0518993 66.5676 0.592147 66.2355 1.85703C66.0618 2.42178 66.2336 2.9788 66.3977 3.52227C66.7278 4.69626 67.0425 5.87411 67.3784 7.04809C70.8072 18.7009 73.342 28.1198 73.0969 39.6856C73.1471 46.9693 72.9327 54.2415 72.342 61.502C72.3227 61.8385 72.3053 62.177 72.2879 62.5135C70.9809 59.8387 69.4345 56.7287 69.1932 55.9318C66.9672 50.3172 65.4285 44.4492 63.9343 38.6045C62.2392 31.6263 61.3222 24.4934 60.1137 17.4244C59.5441 15.4652 56.5826 16.2446 57.0903 18.2599C58.824 31.5296 61.2681 48.2594 66.8861 62.0996C67.3205 63.2272 69.5832 68.3428 70.6488 69.0062C70.9828 69.2886 71.3477 69.4356 71.7107 69.4839C70.784 76.5452 69.3226 83.9063 67.4943 91.366C67.3167 90.3622 67.1371 89.3585 66.9595 88.3547C65.7896 81.1425 64.0791 74.0174 61.9439 67.0315C59.8106 60.6413 57.2892 54.3846 55.1269 48.0021C53.2736 42.401 51.592 36.7439 49.87 31.1022C49.395 29.3847 46.7212 30.0655 47.1961 31.841C49.8815 43.2694 53.1616 54.5277 57.0421 65.6061C59.8588 74.5918 61.9574 83.7941 63.7316 93.0371C64.2528 96.031 64.4439 97.0174 64.6158 99.2783C64.7393 99.9417 64.6119 100.667 64.8609 101.296C64.2374 103.493 63.5791 105.69 62.9072 107.885C62.7759 105.903 62.5906 103.976 62.357 102.305C61.1909 96.1896 60.3106 89.989 58.5673 84.003C56.9456 79.1446 55.1752 74.3268 53.1964 69.6038C52.2195 67.2965 50.4395 63.8113 49.4452 61.0765C49.1981 60.4672 49.0842 59.7284 48.4587 59.388C47.3447 58.6918 45.8292 59.8387 46.2231 61.1113C47.3003 64.2348 48.6421 67.2636 49.6827 70.4007C51.177 75.3383 52.5477 80.3147 54.0574 85.2505C57.0575 98.2436 58.4186 106.268 59.2082 116.524C59.2622 117.395 59.2487 118.358 59.2584 119.166C58.7043 120.793 58.1386 122.412 57.5672 124.023C57.154 122.816 56.7371 121.613 56.3896 120.391C54.0246 112.09 51.2658 103.912 48.4278 95.7622C47.6189 93.4548 46.6999 91.1881 45.9431 88.8614C45.4894 87.8402 45.5261 86.4303 44.5242 85.7707C43.3349 85.0261 41.7209 86.2543 42.1399 87.6081C44.0106 93.4587 45.4856 99.4098 46.9914 105.361C49.3043 113.262 52.6519 125.288 54.9802 130.926C54.9879 130.958 55.0053 130.984 55.015 131.016C54.2678 133.016 53.5168 135.003 52.7542 136.96C51.7059 139.882 50.6769 142.689 49.6556 145.427C49.3332 142.354 48.895 139.2 48.4742 136.801C47.0687 129.003 45.6941 121.184 43.75 113.5C43.084 110.877 42.3214 108.278 41.7036 105.641C41.248 104.173 41.219 102.497 40.4854 101.151C39.4718 99.7193 37.1068 100.891 37.6107 102.586C39.0316 109.233 39.2093 112.409 40.3078 118.466C42.2326 129.195 44.7076 142.397 45.7636 151.2C45.9586 153.251 45.9779 152.744 46.059 154.79C45.1265 157.142 44.1825 159.455 43.2172 161.757C41.4024 149.137 38.7961 136.645 35.6705 124.284C35.3037 122.665 34.9253 121.05 34.5121 119.443C34.3905 118.996 34.0913 118.611 33.6955 118.377C32.3924 117.561 30.6239 118.907 31.0815 120.391C33.9098 135.192 37.9119 157.976 39.3231 168.273C39.3231 168.866 39.493 169.352 39.7653 169.723C38.3791 172.816 36.9234 175.937 35.3848 179.123C35.1434 175.636 34.6994 172.158 34.3249 168.683C33.2939 159.179 30.622 149.984 28.2686 140.751C27.923 139.335 27.5987 137.915 27.3033 136.488C27.1933 136.08 26.9191 135.73 26.5581 135.517C25.3689 134.774 23.7549 135.999 24.1738 137.354C26.2183 149.073 29.1393 162.606 29.9637 172.684C30.4637 177.133 30.8576 181.604 30.6761 186.085C30.6529 187.064 30.5853 188.039 30.512 189.014C24.6121 200.537 16.4785 213.977 11.6791 222.193C8.88358 227.067 6.10163 231.954 3.10535 236.706C2.5088 237.679 1.90644 238.646 1.2983 239.611C0.773184 240.303 0.678612 241.354 1.27709 242.029C3.55712 244.342 4.94517 241.011 6.49929 239.388C7.48969 238.131 10.847 233.778 12.7293 230.89C20.9652 218.843 29.0544 205.099 36.4022 190.638C47.4509 182.122 64.1177 170.067 77.3461 158.415C78.9871 157.039 80.6165 155.649 82.2806 154.301C83.0258 153.57 84.1726 153.131 84.6167 152.16C85.3812 150.593 83.4235 148.868 81.9505 149.808C71.4462 157.457 59.4437 167.296 49.2715 174.864C46.5764 176.916 43.8619 178.941 41.1514 180.972C42.5646 177.995 43.945 175.001 45.281 171.992C45.3678 171.924 45.4508 171.851 45.5281 171.767C50.4704 168.259 62.8609 159.6 70.0407 153.552C76.5912 148.017 82.8946 142.116 88.6574 135.759C90.1246 134.246 87.9122 131.984 86.3696 133.467C79.2342 140.876 70.228 147.748 65.3707 151.494C61.7643 154.169 58.1927 156.896 54.5536 159.525C52.7041 160.795 50.841 162.053 49.0012 163.339C49.6518 161.768 50.2908 160.198 50.9144 158.625C51.4183 158.221 51.8797 157.626 52.8624 156.629C60.4554 148.86 68.0561 141.087 75.9271 133.598C79.6338 129.96 83.1533 126.139 86.7384 122.383C89.14 119.838 91.9819 117.041 93.3719 115.138C93.9028 114.258 94.9685 113.416 94.6924 112.295C94.3873 111.053 92.5726 110.803 91.9548 111.937C89.8524 115.006 84.1205 119.4 79.8559 123.574C71.4945 131.363 63.2064 139.221 55.1675 147.342C55.3509 146.826 55.5478 146.307 55.7293 145.791C57.3413 141.196 58.9958 136.612 60.6098 132.013C61.2256 131.523 61.7797 130.906 62.4091 130.469C65.1003 128.224 67.8688 126.069 70.533 123.791C76.6472 118.917 82.5451 113.766 87.8851 108.04C90.6381 105.057 93.2367 101.934 95.7755 98.7677C96.6674 97.6769 98.3528 95.4508 98.4126 95.3367C98.7717 94.7255 99.4165 94.2072 99.5111 93.4819C99.8026 91.9559 97.54 91.1726 96.8025 92.5439C89.2076 102.208 75.5912 113.49 63.5713 123.319C64.6235 120.118 65.6313 116.904 66.556 113.662C66.6661 113.558 66.7761 113.451 66.9151 113.327C69.9693 110.204 73.0235 107.075 76.0796 103.951C81.9525 97.8896 88.0956 92.041 93.2232 85.3124C95.6461 82.2198 96.8855 80.4656 99.4397 76.8411C100.712 74.8819 102.521 72.9691 103.399 70.8339C103.681 69.3582 101.49 68.5942 100.776 69.9249C97.6578 74.2088 92.5205 80.0343 88.1786 84.8675C81.854 91.4105 75.3576 97.7871 68.9847 104.284C69.5272 102 70.0485 99.7096 70.535 97.4139C78.3616 86.1924 82.3289 81.8233 90.6362 71.1453L90.6323 71.1433Z"
                fill="black"
              />
            </svg>
          </div>
        </ImageTrailItem>
        <ImageTrailItem>
          <div className="w-20 sm:w-28 relative overflow-hidden aspect-[9/16]">
            <video
              src={
                "https://cdn.cosmos.so/96ae0b34-289d-489d-94a1-c68925ddd3a9.mp4"
              }
              className="w-full h-full object-cover absolute inset-0"
              autoPlay
              muted
              playsInline
              loop
            />
          </div>
        </ImageTrailItem>
        <ImageTrailItem>
          <div className="w-28 sm:w-36 aspect-video text-center border bg-white border-black grid place-items-center text-sm sm:text-base">
            Hey, this is just a simple div
          </div>
        </ImageTrailItem>
      </ImageTrail>
      <p className="text sm:text-lg absolute top-4 left-6 font-medium pointer-events-none z-100">
        move your cursor
      </p>
    </div>
  )
}

export default ImageTrailVariousElementsDemo
src/fancy/examples/image/image-trail-zindex-demo.tsx
파일 저장

import ImageTrail,{
  ImageTrailItem,
} from "@/fancy/components/image/image-trail"

const images = [
  "https://cdn.cosmos.so/7dc46d69-ad3b-4942-ab84-511ae786892e?format=jpeg",
  "https://cdn.cosmos.so/cb5c5995-4ba7-4519-a0e8-aa6427cc0a90?format=jpeg",
  "https://cdn.cosmos.so/264d0ae9-f2e9-4deb-843c-229e70bbe4cc?format=jpeg",
  "https://cdn.cosmos.so/d9ed5da2-92b8-4b71-84e8-7bef645c44dc?format=jpeg",
  "https://cdn.cosmos.so/223c2fad-7dcb-46e0-9f00-826edcf8d7b1?format=jpeg",
  "https://cdn.cosmos.so/48e640ee-75e1-4390-a34a-b790785f033a?format=jpeg",
  "https://cdn.cosmos.so/d9c19f7c-d605-4257-8546-dafe0daa250f.?format=jpeg",
  "https://cdn.cosmos.so/5ff27be0-bed8-4779-b520-6896e68e7e4d?format=jpeg",
  "https://cdn.cosmos.so/9098d86e-b3f7-425f-be96-40df45c82342?format=jpeg",
  "https://cdn.cosmos.so/5c01be2f-57fe-4a1a-91ad-a37b9426e080?format=jpeg",
  "https://cdn.cosmos.so/6a1d9c63-5b32-4a22-b03e-5dc63164ad8a?format=jpeg",
]

const ImageTrailDemo = () => {
  return (
    <div className="w-full h-full bg-white relative text-foreground dark:text-muted">
      <ImageTrail
        threshold={1}
        intensity={1}
        className="perspective-400"
        keyframes={{ scale: [1, 0], rotateX: [0, -90], rotateY: [0, -90], rotateZ: [0, 360] }}
        keyframesOptions={{
          scale: { duration: 1, type: "tween", ease: "easeOut" },
          rotateZ: { duration: 3, type: "tween", ease: "easeOut" },
          rotateY: { duration: 3, type: "tween", ease: "easeOut" },
          rotateX: { duration: 3, type: "tween", ease: "easeOut" },
        }}
        repeatChildren={10}
        zIndexDirection="old-on-top"
      >
        {images.map((url, index) => (
          <ImageTrailItem key={index} className="transform-3d">
            <div className="sm:w-36 sm:h-36 w-30 h-30 relative overflow-hidden rounded-xl">
              <img src={url} alt="image" className="object-cover" />
            </div>
          </ImageTrailItem>
        ))}
      </ImageTrail>
      <p className="text sm:text-lg absolute top-4 left-6 font-medium pointer-events-none z-100">
        move your cursor
      </p>
    </div>
  )
}

export default ImageTrailDemo
src/fancy/examples/image/parallax-floating-demo.tsx
파일 저장

"use client"

import { useEffect } from "react"
import { exampleImages } from "@/utils/demo-images"
import { motion, stagger, useAnimate } from "motion/react"

import Floating, {
  FloatingElement,
} from "@/fancy/components/image/parallax-floating"

const Preview = () => {
  const [scope, animate] = useAnimate()

  useEffect(() => {
    animate("img", { opacity: [0, 1] }, { duration: 0.5, delay: stagger(0.15) })
  }, [])

  return (
    <div
      className="flex w-full h-full justify-center items-center bg-black overflow-hidden"
      ref={scope}
    >
      <motion.div
        className="z-50 text-center space-y-4 items-center flex flex-col"
        initial={{ opacity: 0, y: 10 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.88, delay: 1.5 }}
      >
        <p className="text-5xl md:text-7xl z-50 text-white font-calendas italic">
          fancy.
        </p>
        <p className="text-xs z-50 hover:scale-110 transition-transform bg-white text-black rounded-full py-2 w-20 cursor-pointer">
          Download
        </p>
      </motion.div>

      <Floating sensitivity={-1} className="overflow-hidden">
        <FloatingElement depth={0.5} className="top-[8%] left-[11%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[0].url}
            className="w-16 h-16 md:w-24 md:h-24 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>
        <FloatingElement depth={1} className="top-[10%] left-[32%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[1].url}
            className="w-20 h-20 md:w-28 md:h-28 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>
        <FloatingElement depth={2} className="top-[2%] left-[53%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[2].url}
            className="w-28 h-40 md:w-40 md:h-52 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>
        <FloatingElement depth={1} className="top-[0%] left-[83%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[3].url}
            className="w-24 h-24 md:w-32 md:h-32 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>

        <FloatingElement depth={1} className="top-[40%] left-[2%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[4].url}
            className="w-28 h-28 md:w-36 md:h-36 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>
        <FloatingElement depth={2} className="top-[70%] left-[77%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[7].url}
            className="w-28 h-28 md:w-36 md:h-48 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>

        <FloatingElement depth={4} className="top-[73%] left-[15%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[5].url}
            className="w-40 md:w-52 h-full object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>
        <FloatingElement depth={1} className="top-[80%] left-[50%]">
          <motion.img
            initial={{ opacity: 0 }}
            src={exampleImages[6].url}
            className="w-24 h-24 md:w-32 md:h-32 object-cover hover:scale-105 duration-200 cursor-pointer transition-transform"
          />
        </FloatingElement>
      </Floating>
    </div>
  )
}

export default Preview
src/fancy/examples/physics/cursor-attractor-and-gravity-demo.tsx
파일 저장

import { motion } from "motion/react"

import useScreenSize from "@/hooks/use-screen-size"
import Gravity, {
  MatterBody,
} from "@/fancy/components/physics/cursor-attractor-and-gravity"

export default function Preview() {
  const screenSize = useScreenSize()
  const words = [
    "we",
    "analyze",
    { text: "millions", highlight: true },
    { text: "of", highlight: true },
    { text: "data", highlight: true },
    { text: "points", highlight: true },
    "per",
    "second",
    "to",
    "provide",
    "you",
    "with",
    "the",
    "most",
    "accurate",
    "insights.",
  ]

  return (
    <div className="w-full h-full flex flex-col relative font-overused-grotesk justify-center items-center bg-white">
      <Gravity
        attractorStrength={0.0}
        cursorStrength={0.0004}
        cursorFieldRadius={200}
        className="w-full h-full z-0 absolute"
      >
        {[...Array(150)].map((_, i) => {
          // Adjust max size based on screen size
          const maxSize = screenSize.lessThan("sm")
            ? 20
            : screenSize.lessThan("md")
              ? 30
              : 40
          const size = Math.max(
            screenSize.lessThan("sm") ? 10 : 20,
            Math.random() * maxSize
          )
          return (
            <MatterBody
              key={i}
              matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
              x={`${Math.random() * 100}%`}
              y={`${Math.random() * 100}%`}
            >
              <div
                className="rounded-full bg-[#eee]"
                style={{
                  width: `${size}px`,
                  height: `${size}px`,
                }}
              />
            </MatterBody>
          )
        })}
      </Gravity>
      <span className="text z-10 sm:text-lg md:text-xl text-black px-4 py-2 w-2/3 flex flex-wrap whitespace-pre-wrap">
        {words.map((word, index) => {
          const text = typeof word === "string" ? word : word.text
          const highlight = typeof word === "object" && word.highlight

          return (
            <motion.span
              key={index}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              transition={{ duration: 0.5, delay: index * 0.05 }}
              className={`${highlight ? "text-[#0015ff]" : ""} ${index < words.length - 1 ? "mr-1" : ""}`}
            >
              {text}
            </motion.span>
          )
        })}
      </span>
    </div>
  )
}
src/fancy/examples/physics/cursor-attractor-and-gravity-image-demo.tsx
파일 저장

import useScreenSize from "@/hooks/use-screen-size"
import Gravity, {
  MatterBody,
} from "@/fancy/components/physics/cursor-attractor-and-gravity"

export default function Preview() {
  const screenSize = useScreenSize()

  const getImageCount = () => {
    if (screenSize.lessThan("sm")) return 50
    if (screenSize.lessThan("md")) return 60
    if (screenSize.lessThan("lg")) return 70
    return 80
  }

  const getMaxSize = () => {
    if (screenSize.lessThan("sm")) return 40
    if (screenSize.lessThan("md")) return 50
    return 60
  }

  const getMinSize = () => {
    if (screenSize.lessThan("sm")) return 10
    if (screenSize.lessThan("md")) return 20
    return 20
  }

  return (
    <div className="w-full h-full flex flex-col relative justify-center items-center md:items-end bg-white">
      <div>
        <p className="z-20 text-2xl sm:text-3xl md:text-3xl text-foreground dark:text-muted md:pr-24">
          join the <span className="font-calendas  italic">community</span>
        </p>
      </div>
      <Gravity
        attractorPoint={{ x: "33%", y: "50%" }}
        attractorStrength={0.0005}
        cursorStrength={-0.004}
        cursorFieldRadius={screenSize.lessThan("sm") ? 100 : 200}
        className="w-full h-full"
      >
        {[...Array(getImageCount())].map((_, i) => {
          const size = Math.max(getMinSize(), Math.random() * getMaxSize())
          return (
            <MatterBody
              key={i}
              matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
              x={`${Math.random() * 100}%`}
              y={`${Math.random() * 30}%`}
            >
              <img
                src={`https://randomuser.me/api/portraits/${i % 2 === 0 ? "men" : "women"}/${i}.jpg`}
                alt={`Avatar ${i}`}
                className="rounded-full object-cover hover:cursor-pointer"
                style={{
                  width: `${size}px`,
                  height: `${size}px`,
                }}
              />
            </MatterBody>
          )
        })}
      </Gravity>
    </div>
  )
}
src/fancy/examples/physics/cursor-attractor-and-gravity-svg-demo.tsx
파일 저장

import { useState } from "react"

import Gravity, {
  MatterBody,
} from "@/fancy/components/physics/cursor-attractor-and-gravity"

export default function Preview() {
  const [debug, setDebug] = useState(false)

  return (
    <div className="w-full h-full flex flex-col relative justify-center items-center bg-white">
      <button
        onClick={() => setDebug(!debug)}
        className="absolute top-4 left-4 px-4 py-2 text-xs border border-border rounded-lg bg-background hover:bg-accent cursor-pointer z-10"
      >
        {debug ? "Disable Debug" : "Enable Debug"}
      </button>
      <p className="z-20 text-2xl sm:text-3xl md:text-3xl text-black">
        fancy components
      </p>
      <Gravity
        //attractorPoint={{ x: "33%", y: "50%" }}
        attractorStrength={0.0}
        cursorStrength={0.0005}
        cursorFieldRadius={200}
        className="w-full h-full"
        debug={debug}
      >
        {["#0015FF", "#E794DA"].map((color, i) => (
          <>
            <MatterBody
              key={`star1-${i}`}
              matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
              x={`${10 + Math.random() * 80}%`}
              y={`${10 + Math.random() * 80}%`}
              bodyType="svg"
            >
              <svg
                width="111"
                height="108"
                viewBox="0 0 111 108"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path
                  d="M19.9185 107.176L33.6145 66.472L0.5905 44.328H41.0385L55.5025 0.679993L70.2225 44.328L110.415 44.328L77.3905 66.472L91.3425 107.176L55.5025 81.192L19.9185 107.176Z"
                  fill={color}
                />
              </svg>
            </MatterBody>

            <MatterBody
              key={`star2-${i}`}
              matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
              x={`${10 + Math.random() * 80}%`}
              y={`${10 + Math.random() * 80}%`}
              bodyType="svg"
            >
              <svg
                width="152"
                height="153"
                viewBox="0 0 152 153"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path
                  d="M45.3648 152.4L41.7648 150.8L52.5648 100L1.76484 110.8L0.164844 107.2L41.7648 76.4L0.164844 46L1.76484 42.4L52.5648 52.8L41.7648 2.39999L45.3648 0.799989L76.1648 42.4L106.565 0.799989L110.165 2.39999L99.7648 52.8L150.165 42.4L151.765 46L110.165 76.4L151.765 107.2L150.165 110.8L99.7648 100L110.165 150.8L106.565 152.4L76.1648 110.8L45.3648 152.4Z"
                  fill={color}
                />
              </svg>
            </MatterBody>

            <MatterBody
              key={`star3-${i}`}
              matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
              x={`${10 + Math.random() * 80}%`}
              y={`${10 + Math.random() * 80}%`}
              angle={10}
              bodyType="svg"
            >
              <svg
                width="99"
                height="99"
                viewBox="0 0 99 99"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path
                  d="M46.9325 98.376C45.0125 87.368 37.2045 73.544 22.3565 62.408C15.0605 56.904 7.6365 53.32 0.3405 51.784V46.408C14.8045 42.952 29.0125 33.224 38.1005 20.04C42.7085 13.384 45.6525 6.856 46.9325 0.0719986H52.3085C54.4845 13 64.4685 27.336 78.0365 36.936C84.6925 41.672 91.6045 44.872 98.6445 46.408V51.784C84.4365 54.728 67.9245 67.4 59.7325 80.328C55.6365 86.856 53.2045 92.872 52.3085 98.376H46.9325Z"
                  fill={color}
                />
              </svg>
            </MatterBody>
          </>
        ))}
      </Gravity>
    </div>
  )
}
src/fancy/examples/physics/elastic-line-demo.tsx
파일 저장

"use client"

import { motion, Variants } from "motion/react"

import ElasticLine from "@/fancy/components/physics/elastic-line"

export default function Preview() {
  const textVariants = {
    hidden: { opacity: 0, y: 10 },
    visible: (i: number) => ({
      opacity: 1,
      y: 0,
      transition: {
        delay: i * 0.3,
        type: "spring",
        stiffness: 300,
        damping: 30,
      },
    }),
  }

  return (
    <div className="w-full h-full flex flex-row items-center justify-center font-overused-grotesk overflow-hidden bg-white text-foreground dark:text-muted">
      <div className="absolute left-0 top-0 w-full h-full px-6 sm:px-8 md:px-12 z-10">
        {/* Animated elastic line */}
        <ElasticLine
          releaseThreshold={50}
          strokeWidth={1}
          animateInTransition={{
            type: "spring",
            stiffness: 300,
            damping: 30,
            delay: 0.15,
          }}
        />
      </div>

      {/* This is just fluff for the demo */}
      <div className="h-full flex flex-col py-6 w-full px-6 sm:px-8 md:px-12 font-light">
        <div className="h-1/2 py-8 w-full items-end flex">
          <motion.p
            variants={textVariants as Variants}
            initial="hidden"
            animate="visible"
            className="uppercase text-4xl sm:text-5xl md:text-6xl font-medium"
            custom={0}
          >
            FANCY COMPONENTS
          </motion.p>
        </div>
        <div className="flex flex-row  pt-8 justify-between items-start gap-x-4">
          <motion.p
            variants={textVariants as Variants}
            initial="hidden"
            animate="visible"
            className="w-1/3 uppercase md:text-7xl hidden md:block text-orange-500"
            custom={0}
          >
            ✽
          </motion.p>
          <motion.p
            variants={textVariants as Variants}
            initial="hidden"
            animate="visible"
            className="w-full md:w-2/3 sm:text-left text-base sm:text-xl md:text-2xl"
            custom={1}
          >
            Ready to use, fancy, animated React components & microinteractions
            for creative developers.
          </motion.p>
        </div>

        {/* <div className="h-1/3 flex items-center justify-end">
          <motion.p
            variants={textVariants}
            initial="hidden"
            animate="visible"
            custom={2}
          >
            
          </motion.p>
        </div> */}
      </div>
    </div>
  )
}
src/fancy/examples/physics/gravity-body-types-demo.tsx
파일 저장

import {
  Atom,
  AudioLines,
  BatteryCharging,
  Brain,
  Cloud,
  Cog,
  Cpu,
  Cuboid,
  Earth,
  Eye,
  Globe,
  HandMetal,
  Heart,
  Laptop,
  Layers,
  MessageCircle,
  Microscope,
  Move,
  PaintRoller,
  PersonStanding,
  Pyramid,
  Regex,
  Rocket,
  Satellite,
  Save,
  ScanFace,
  Settings,
  Sigma,
  Sparkles,
  Star,
  Sun,
  TrendingUp,
  Zap,
} from "lucide-react"

import Gravity, { MatterBody } from "@/fancy/components/physics/gravity"

export default function Preview() {
  const icons = [
    { icon: Atom, size: 24 },
    { icon: Brain, size: 24 },
    { icon: Cog, size: 24 },
    { icon: Cpu, size: 24 },
    { icon: TrendingUp, size: 24 },
    { icon: Globe, size: 24 },
    { icon: Laptop, size: 24 },
    { icon: Microscope, size: 24 },
    { icon: Pyramid, size: 24 },
    { icon: Rocket, size: 24 },
    { icon: PaintRoller, size: 24 },
    { icon: Eye, size: 24 },
    { icon: ScanFace, size: 24 },
    { icon: PersonStanding, size: 24 },
    { icon: Sun, size: 24 },
    { icon: Sparkles, size: 24 },
    { icon: Regex, size: 24 },
    { icon: Cloud, size: 24 },
    { icon: Settings, size: 24 },
    { icon: MessageCircle, size: 24 },
    { icon: Cuboid, size: 24 },
    { icon: Atom, size: 24 },
    { icon: Brain, size: 24 },
    { icon: AudioLines, size: 24 },
    { icon: BatteryCharging, size: 24 },
    { icon: Satellite, size: 24 },
    { icon: Move, size: 24 },
    { icon: Star, size: 24 },
    { icon: HandMetal, size: 24 },
    { icon: Heart, size: 24 },
    { icon: Save, size: 24 },
    { icon: Layers, size: 24 },
    { icon: Earth, size: 24 },
    { icon: Zap, size: 24 },
    { icon: Sigma, size: 24 },
  ]

  return (
    <div className="w-full h-full flex flex-col items-center relative bg-white">
      <h2 className=" text-black pt-24 text-xl ponter-events-none">
        icons from lucide.dev
      </h2>
      <Gravity gravity={{ x: 0, y: 1 }} className="w-full h-full">
        {icons.map((IconData, index) => {
          const Icon = IconData.icon
          const randomX = Math.random() * 60 + 20 // Random x between 20-80%
          const randomY = Math.random() * 20 + 5 // Random y between 5-25%
          const bodyType = Math.random() > 0.7 ? "rectangle" : "circle"

          return (
            <MatterBody
              key={index}
              matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
              bodyType={bodyType}
              x={`${randomX}%`}
              y={`${randomY}%`}
            >
              <div
                className={`p-4 ${
                  bodyType === "circle" ? "rounded-full" : "rounded-md"
                } bg-white border border-border shadow-md text-foreground dark:text-muted`}
              >
                <Icon size={IconData.size} />
              </div>
            </MatterBody>
          )
        })}
      </Gravity>
    </div>
  )
}
src/fancy/examples/physics/gravity-demo.tsx
파일 저장

import Gravity, { MatterBody } from "@/fancy/components/physics/gravity"

export default function Preview() {
  return (
    <div className="w-full h-full flex flex-col relative font-azeret-mono bg-white">
      <div className="pt-20 text-6xl sm:text-7xl md:text-8xl text-foreground dark:text-muted w-full text-center font-calendas italic">
        fancy
      </div>
      <p className="pt-4 text-base sm:text-xl md:text-2xl text-foreground dark:text-muted w-full text-center">
        components made with:
      </p>
      <Gravity gravity={{ x: 0, y: 1 }} className="w-full h-full">
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="30%"
          y="10%"
        >
          <div className="text-xl sm:text-2xl md:text-3xl bg-primary-blue text-white rounded-full hover:cursor-pointer px-8 py-4">
            react
          </div>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="30%"
          y="30%"
        >
          <div className="text-xl sm:text-2xl md:text-3xl bg-primary-pink text-white rounded-full hover:cursor-grab px-8 py-4 ">
            typescript
          </div>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="40%"
          y="20%"
          angle={10}
        >
          <div className="text-xl sm:text-2xl md:text-3xl bg-teal text-white rounded-full hover:cursor-grab px-8 py-4 ">
            motion
          </div>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="75%"
          y="10%"
        >
          <div className="text-xl sm:text-2xl md:text-3xl bg-primary-red text-white rounded-full hover:cursor-grab px-8 py-4 ">
            tailwind
          </div>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="80%"
          y="20%"
        >
          <div className="text-xl sm:text-2xl md:text-3xl bg-primary-orange text-white rounded-full hover:cursor-grab px-8 py-4 ">
            drei
          </div>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="50%"
          y="10%"
        >
          <div className="text-xl sm:text-2xl md:text-3xl bg-yellow-foreground text-white rounded-full hover:cursor-grab px-8 py-4 ">
            matter-js
          </div>
        </MatterBody>
      </Gravity>
    </div>
  )
}
src/fancy/examples/physics/gravity-non-draggable-demo.tsx
파일 저장

"use client"

import { motion } from "motion/react"

import Gravity, { MatterBody } from "@/fancy/components/physics/gravity"

const socialLinks = [
  { name: "LinkedIn", x: "30%", y: "10%" },
  { name: "X (Twitter)", x: "30%", y: "30%" },
  { name: "Instagram", x: "40%", y: "20%", angle: 10 },
  { name: "GitHub", x: "75%", y: "10%", angle: -4 },
  { name: "BlueSky", x: "80%", y: "20%", angle: 5 },
]

const stars = ["✱", "✽", "✦", "✸", "✹", "✺"]

export default function Preview() {
  return (
    <div className="w-full h-full flex flex-col relative bg-white font-calendas">
      <p className="pt-4 text-6xl sm:text-7xl md:text-9xl text-primary-blue w-full text-center font-calendas">
        CONTACT
      </p>
      <Gravity gravity={{ x: 0, y: 1 }} className="w-full h-full">
        {socialLinks.map((link) => (
          <MatterBody
            key={link.name}
            matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
            x={link.x}
            y={link.y}
            angle={link.angle || 0}
            isDraggable={false}
          >
            <motion.div
              className="text-xl sm:text-2xl md:text-3xl bg-white text-primary-blue border border-primary-blue rounded-full hover:cursor-pointer hover:bg-primary-blue hover:text-white md:px-8 md:py-4 py-3 px-6"
              whileTap={{ scale: 0.9 }}
            >
              {link.name}
            </motion.div>
          </MatterBody>
        ))}

        {stars.map((star, i) => (
          <MatterBody
            key={i}
            matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
            x={`${Math.random() * 60 + 20}%`}
            y={`${Math.random() * 20 + 40}%`}
            angle={Math.random() * 360}
          >
            <div
              className={`aspect-square w-12 h-12 sm:w-14 sm:h-14 md:w-16 md:h-16 bg-primary-blue text-white rounded-lg text-center`}
            ></div>
          </MatterBody>
        ))}
      </Gravity>
    </div>
  )
}
src/fancy/examples/physics/gravity-svg-bodies-demo.tsx
파일 저장

"use client"

import { useState } from "react"

import Gravity, { MatterBody } from "@/fancy/components/physics/gravity"

export default function Preview() {
  const [debug, setDebug] = useState(false)

  return (
    <div className="w-full h-full flex flex-col relative  bg-white">
      <button
        onClick={() => setDebug(!debug)}
        className="absolute top-4 left-4 px-4 py-2 text-xs border border-border rounded-lg bg-background hover:bg-accent cursor-pointer z-10"
      >
        {debug ? "Disable Debug" : "Enable Debug"}
      </button>
      <Gravity gravity={{ x: 0, y: 1 }} className="w-full h-full" debug={debug}>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="30%"
          y="10%"
          bodyType="svg"
        >
          <svg
            width="111"
            height="108"
            viewBox="0 0 111 108"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              d="M19.9185 107.176L33.6145 66.472L0.5905 44.328H41.0385L55.5025 0.679993L70.2225 44.328L110.415 44.328L77.3905 66.472L91.3425 107.176L55.5025 81.192L19.9185 107.176Z"
              fill="#1F464D"
            />
          </svg>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="80%"
          y="30%"
          bodyType="svg"
        >
          <svg
            width="152"
            height="153"
            viewBox="0 0 152 153"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              d="M45.3648 152.4L41.7648 150.8L52.5648 100L1.76484 110.8L0.164844 107.2L41.7648 76.4L0.164844 46L1.76484 42.4L52.5648 52.8L41.7648 2.39999L45.3648 0.799989L76.1648 42.4L106.565 0.799989L110.165 2.39999L99.7648 52.8L150.165 42.4L151.765 46L110.165 76.4L151.765 107.2L150.165 110.8L99.7648 100L110.165 150.8L106.565 152.4L76.1648 110.8L45.3648 152.4Z"
              fill="#0015FF"
            />
          </svg>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="40%"
          y="20%"
          angle={10}
          bodyType="svg"
        >
          <svg
            width="99"
            height="99"
            viewBox="0 0 99 99"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              d="M46.9325 98.376C45.0125 87.368 37.2045 73.544 22.3565 62.408C15.0605 56.904 7.6365 53.32 0.3405 51.784V46.408C14.8045 42.952 29.0125 33.224 38.1005 20.04C42.7085 13.384 45.6525 6.856 46.9325 0.0719986H52.3085C54.4845 13 64.4685 27.336 78.0365 36.936C84.6925 41.672 91.6045 44.872 98.6445 46.408V51.784C84.4365 54.728 67.9245 67.4 59.7325 80.328C55.6365 86.856 53.2045 92.872 52.3085 98.376H46.9325Z"
              fill="#E794DA"
            />
          </svg>
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="75%"
          y="10%"
          bodyType="circle"
        >
          <div className="w-32 h-32 bg-red  text-white [#E794DA] rounded-full hover:cursor-grab px-8 py-4 " />
        </MatterBody>
        <MatterBody
          matterBodyOptions={{ friction: 0.5, restitution: 0.2 }}
          x="80%"
          y="20%"
        >
          <div className="w-16 h-16 bg-orange-500  text-white [#E794DA] rounded-lg hover:cursor-grab px-8 py-4 " />
        </MatterBody>
        <MatterBody
          matterBodyOptions={{
            friction: 0.5,
            restitution: 0.2,
            isStatic: true,
          }}
          x="50%"
          y="95%"
          bodyType="svg"
        >
          <svg
            width="298"
            height="125"
            viewBox="0 0 298 125"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              d="M10.7705 44.16H2.3225L1.8105 41.344C1.8105 41.344 7.9545 39.936 10.7705 37.376V32.512C10.7705 16.128 19.3465 -7.62939e-06 37.9065 -7.62939e-06C48.1465 -7.62939e-06 53.2665 3.96799 53.2665 8.448C53.2665 12.032 50.0665 14.336 46.9945 14.336C46.2265 14.336 45.7145 14.208 45.7145 14.208C45.0745 12.288 42.1305 5.24799 35.0905 5.24799C25.8745 5.24799 21.7785 14.848 21.7785 28.672V38.4H42.2585V44.416H21.7785V84.48C21.7785 90.496 23.4425 91.776 27.2825 92.032L34.1945 92.544V96C34.1945 96 22.0345 95.616 16.4025 95.616C10.5145 95.616 0.5305 96 0.5305 96V92.544L3.9865 92.032C7.8265 91.52 10.7705 88.32 10.7705 83.456V44.16ZM58.4255 83.584C58.4255 89.216 62.1375 91.648 66.4895 91.648C72.6335 91.648 74.9375 88.96 80.1855 87.68C80.1855 87.68 79.5455 84.096 79.5455 79.36C79.5455 74.752 79.6735 70.528 79.6735 70.528C68.1535 70.528 58.4255 74.752 58.4255 83.584ZM79.8015 66.432C79.8015 66.432 80.1855 57.856 80.1855 53.632C80.1855 46.848 77.8815 41.728 69.8175 41.728C63.6735 41.728 61.2415 45.056 61.2415 48.256C61.2415 50.304 62.0095 51.968 62.9055 52.992C61.2415 54.272 59.0655 54.912 56.7615 54.912C52.6655 54.912 49.9775 52.736 49.9775 49.024C49.9775 42.112 60.8575 36.352 72.6335 36.352C85.8175 36.352 90.8095 44.032 90.8095 55.936C90.8095 64 90.1695 71.936 90.1695 80.512C90.1695 86.912 90.9375 91.008 96.1855 91.008C99.3855 91.008 101.434 89.728 101.434 89.728L102.586 91.904C102.586 91.904 98.7455 98.048 91.3215 98.048C85.3055 98.048 82.1055 94.208 81.0815 90.496C75.8335 91.648 72.1215 98.048 61.8815 98.048C52.6655 98.048 47.1615 93.44 47.1615 84.992C47.1615 70.656 64.9535 66.432 79.8015 66.432ZM170.138 92.032L174.106 92.544V96C174.106 96 165.658 95.616 160.026 95.616C153.754 95.616 144.666 96 144.666 96V92.544L147.354 92.032C151.194 91.264 154.138 88.32 154.138 83.456V54.656C154.138 47.744 150.682 44.16 144.026 44.16C137.242 44.16 132.378 48.512 127.514 49.408V84.48C127.514 90.496 128.41 91.648 132.506 92.032L136.602 92.544V96C136.602 96 127.77 95.616 122.01 95.616C116.25 95.616 106.138 96 106.138 96V92.544L109.722 92.032C113.562 91.52 116.506 88.32 116.506 83.456V52.864C116.506 47.36 114.33 45.184 106.906 45.184L106.138 42.368C118.042 41.216 127.514 36.48 127.514 36.48V45.824C130.586 44.928 138.266 36.352 148.25 36.352C159.642 36.352 165.018 43.776 165.018 53.248V84.48C165.018 90.496 166.042 91.52 170.138 92.032ZM207.426 42.112C198.466 42.112 191.298 49.408 191.298 66.048C191.298 77.952 198.082 89.856 213.314 89.856C219.202 89.856 224.194 88.704 227.906 86.912L229.186 89.344C227.522 91.392 220.61 98.048 207.554 98.048C197.826 98.048 180.034 91.392 180.034 68.608C180.034 52.096 191.938 36.352 212.418 36.352C220.994 36.352 230.338 39.808 230.338 46.72C230.338 50.56 226.114 52.992 222.146 52.992C220.866 52.992 219.714 52.736 218.562 52.224C218.562 52.224 219.074 50.944 219.074 49.28C219.074 46.208 216.77 42.112 207.426 42.112ZM263.396 38.4V41.856L260.196 42.368C257.636 42.752 256.356 43.648 256.356 45.312C256.356 45.568 256.356 46.208 256.612 47.104L268.004 83.968L282.084 47.872C282.468 46.72 282.724 45.952 282.724 45.44C282.724 43.904 281.7 42.752 279.396 42.368L275.94 41.856V38.4C275.94 38.4 281.316 38.784 288.1 38.784C293.732 38.784 297.956 38.4 297.956 38.4V41.856L296.036 42.24C292.196 43.008 289.38 44.928 287.332 49.92L263.396 106.624C259.044 116.992 252.9 124.416 242.66 124.416C233.444 124.416 230.883 119.68 230.883 115.712C230.883 112.896 233.059 109.824 237.796 109.824C238.564 109.824 239.588 109.952 239.844 110.08C239.716 110.464 239.588 110.976 239.588 111.488C239.588 115.072 241.764 116.48 245.348 116.48C251.236 116.48 256.1 113.024 259.3 104.96L262.5 97.024L245.988 50.304C243.94 44.416 241.635 42.88 238.436 42.368L234.98 41.856V38.4C234.98 38.4 244.708 38.784 250.468 38.784C255.46 38.784 263.396 38.4 263.396 38.4Z"
              fill="black"
            />
          </svg>
        </MatterBody>
      </Gravity>
    </div>
  )
}
src/fancy/examples/text/basic-number-ticker-demo.tsx
파일 저장

import NumberTicker from "@/fancy/components/text/basic-number-ticker"

const NumberTickerDemo = () => {
  return (
    <div className="p-10 flex w-full h-full justify-center items-center bg-white">
      <p className="w-full text-7xl md:text-9xl flex justify-center font-azeret-mono text-teal">
        <NumberTicker
          from={0}
          target={100}
          autoStart={true}
          transition={{ duration: 3.5, type: "tween", ease: "easeInOut" }}
          onComplete={() => console.log("complete")}
          onStart={() => console.log("start")}
        />
        %
      </p>
    </div>
  )
}

export default NumberTickerDemo
src/fancy/examples/text/breathing-text-demo.tsx
파일 저장

import BreathingText from "@/fancy/components/text/breathing-text"

export default function Preview() {
  return (
    <div className="w-full h-full text-3xl sm:text-4xl md:text-5xl flex flex-row gap-12 items-center justify-center font-overused-grotesk bg-white">
      <div className="flex flex-col items-center justify-center text-black">
        <BreathingText
          staggerDuration={0.08}
          fromFontVariationSettings="'wght' 100, 'slnt' 0"
          toFontVariationSettings="'wght' 800, 'slnt' -10"
        >
          overused grotesk
        </BreathingText>
      </div>
    </div>
  )
}
src/fancy/examples/text/fancy-basic-number-ticker-demo.tsx
파일 저장

"use client"

import { useEffect, useRef } from "react"
import {
  Activity,
  ArrowDownRight,
  DollarSign,
  LucideIcon,
  TrendingUp,
  Zap,
} from "lucide-react"
import { motion, useInView } from "motion/react"

import NumberTicker, {
  NumberTickerRef,
} from "@/fancy/components/text/basic-number-ticker"

const cards = [
  {
    title: "Revenue",
    icon: DollarSign,
    from: 0,
    target: 1250321,
    prefix: "$",
    suffix: "",
    gradient: "from-gray-100 to-blue-400",
    size: "large",
  },
  {
    title: "Conversion Rate",
    icon: TrendingUp,
    from: 0,
    target: 12.5,
    prefix: "",
    suffix: "%",
    gradient: "from-gray-100 to-purple-200",
    size: "small",
  },
  {
    title: "Bounce Rate",
    icon: ArrowDownRight,
    from: 100,
    target: 35.8,
    prefix: "",
    suffix: "%",
    gradient: "from-gray-100 to-orange-200",
    size: "small",
  },
  {
    title: "Avg. Session Duration",
    icon: Zap,
    from: 0,
    target: 245,
    prefix: "",
    suffix: "s",
    gradient: "from-gray-100 to-purple-200",
    size: "small",
  },
  {
    title: "New Users",
    icon: TrendingUp,
    from: 0,
    target: 15420,
    prefix: "",
    suffix: "",
    gradient: "from-gray-100 to-orange-200",
    size: "small",
  },
  {
    title: "Active Users",
    icon: Activity,
    from: 0,
    target: 8750,
    prefix: "",
    suffix: "",
    gradient: "from-gray-100 to-blue-200",
    size: "small",
  },
]

interface CardProps {
  title: string
  icon: LucideIcon
  from: number
  target: number
  prefix: string
  suffix: string
  gradient: string
  size: string
}

const Card = ({ card, index }: { card: CardProps; index: number }) => {
  const cardRef = useRef<HTMLDivElement>(null)
  const tickerRef = useRef<NumberTickerRef>(null)
  const inView = useInView(cardRef, { once: false })

  useEffect(() => {
    if (inView) {
      tickerRef.current?.startAnimation()
    }
  }, [inView])

  return (
    <motion.div
      ref={cardRef}
      initial={{ opacity: 0, y: 10 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.5, delay: index * 0.1 }}
      className={`p-6 bg-linear-to-b from-50% to-130% flex justify-between flex-col text-foreground dark:text-muted ${
        card.gradient
      } ${card.size === "large" ? "col-span-2 row-span-2" : ""}`}
    >
      <div className="flex justify-between items-center mb-4">
        <h3 className="text-xs md:text-sm">{card.title}</h3>
        <card.icon className={`h-4 w-4`} />
      </div>
      <div
        className={`${card.size === "large" ? "text-2xl md:text-5xl" : "text-xl md:text-3xl"}`}
      >
        {card.prefix}
        <NumberTicker
          ref={tickerRef}
          from={card.from}
          target={card.target}
          transition={{
            duration: 3,
            ease: "easeInOut",
            type: "tween",
            delay: index * 0.2,
          }}
          className="tabular-nums"
          autoStart={false}
        />
        {card.suffix}
      </div>
    </motion.div>
  )
}

export default function FancyNumberTickerDemo() {
  return (
    <div className="w-full h-full font-azeret-mono bg-white">
      <div className="grid grid-cols-3 grid-rows-2 h-full">
        {cards.map((card, index) => (
          <Card key={index} card={card} index={index} />
        ))}
      </div>
    </div>
  )
}
src/fancy/examples/text/letter-3d-swap-demo.tsx
파일 저장

"use client"

import { useState } from "react"
import Letter3DSwap from "@/fancy/components/text/letter-3d-swap"

export default function Preview() {
  const [debug, setDebug] = useState(false)

  return (
    <div className="relative w-full h-full flex flex-col items-center justify-center p-8 bg-background">
      {/* <button
        className="absolute top-4 left-4 px-2 py-1 bg-background text-foreground rounded-md border text-[8px] cursor-pointer hover:bg-muted"
        onClick={() => setDebug(!debug)}
      >
        Debug: {debug ? "On" : "Off"}
      </button> */}
      <div className="flex flex-col items-center max-w-2xl font-cotham">
        <Letter3DSwap 
          mainClassName="text-2xl sm:text-5xl md:text-7xl bg-background lowercase"
          frontFaceClassName={`bg-background ${debug ? 'border' : ''} text-foreground`}
          secondFaceClassName={`bg-background ${debug ? 'border' : ''} text-foreground`}
          rotateDirection="top"
          staggerDuration={0.03}
          staggerFrom="first"
          transition={{ type: "spring", damping: 25, stiffness: debug ? 50 : 160 }}
        >
          SET YOUR MIND TO IT
        </Letter3DSwap>
      </div>
    </div>
  )
}
src/fancy/examples/text/letter-3d-swap-explanation-left-demo.tsx
파일 저장

/*
 * If you're reading this file, you're probably looking for the explanation demo.
 * Keep in my mind this demo contains some extra transforms that are only needed
 * for making the animation prettier, and not necessary for the actual functionality.
 * There is a lot of messy code here, so I advise you not to try learn from it.
 * Please refer to the actual documentation for more details.
 */
"use client"

import { useEffect, useState } from "react"
import { AnimatePresence, motion } from "motion/react"

interface AxisHelperProps {
  axisLength: number
}

export const AxisHelper = ({ axisLength }: AxisHelperProps) => {
  // Arrowhead size
  const arrowSize = 12

  return (
    <div
      className="pointer-events-none transform-3d"
      style={{
        transform: "translateY(-100px) translateZ(-100px) translateX(-100px)",
      }}
    >
      {/* X axis (red, right) */}
      <svg
        width={axisLength + arrowSize}
        height={arrowSize * 2}
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          transform: `translateY(-${arrowSize}px)`,
        }}
      >
        <line
          x1={0}
          y1={arrowSize}
          x2={axisLength}
          y2={arrowSize}
          stroke="currentColor"
          strokeOpacity="0.6"
          strokeWidth={2}
          strokeDasharray="6,4"
        />
        <polygon
          points={`
              ${axisLength},${arrowSize}
              ${axisLength - arrowSize},${arrowSize - 5}
              ${axisLength - arrowSize},${arrowSize + 5}
            `}
          fill="currentColor"
        />
      </svg>

      {/* Y axis (green, up) */}
      <svg
        width={arrowSize * 10}
        height={axisLength + arrowSize}
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          transform: `translateX(-${arrowSize}px) translateY(-${axisLength}px)`,
        }}
      >
        <line
          x1={arrowSize}
          y1={axisLength}
          x2={arrowSize}
          y2={0}
          stroke="currentColor"
          strokeOpacity="0.6"
          strokeWidth={2}
          strokeDasharray="6,4"
        />
        <polygon
          points={`
              ${arrowSize},0
              ${arrowSize - 5},${arrowSize}
              ${arrowSize + 5},${arrowSize}
            `}
          fill="currentColor"
        />
      </svg>

      <svg
        width={arrowSize * 2}
        height={axisLength + arrowSize}
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          transform: `translateX(-${arrowSize * 1.8}px) translateY(-${axisLength}px) rotateX(90deg) rotateY(90deg) translateX(${axisLength / 2}px) rotateX(180deg) translateY(-${axisLength / 2 - arrowSize * 1}px)`,
        }}
      >
        <line
          x1={arrowSize}
          y1={axisLength}
          x2={arrowSize}
          y2={0}
          stroke="currentColor"
          strokeOpacity="0.6"
          strokeWidth={2}
          strokeDasharray="6,4"
        />
        <polygon
          points={`
              ${arrowSize},0
              ${arrowSize - 5},${arrowSize}
              ${arrowSize + 5},${arrowSize}
            `}
          fill="currentColor"
        />
      </svg>
    </div>
  )
}
export default function Preview() {
  const [step, setStep] = useState(0)
  const totalSteps = 8

  useEffect(() => {
    const interval = setInterval(
      () => {
        setStep((prev) => {
          if (prev === totalSteps - 1) {
            // Wait longer on the last step
            return 0
          }
          return prev + 1
        })
      },
      step === totalSteps - 1 ? 5000 : 3000
    ) // 4 seconds for last step, 2 seconds for others

    return () => clearInterval(interval)
  }, [step])

  const getSecondFaceTransform = () => {
    switch (step) {
      case 0:
        return `rotateY(0deg)`
      case 1:
        return `rotateY(90deg)`
      case 2:
        return `rotateY(90deg) translateX(-50%)`
      case 3:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg)"
      case 4:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateX(50%)"
      case 5:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateX(50%) rotateY(-90deg)"
      case 6:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateX(50%) rotateY(-90deg) translateX(-50%)"
      case 7:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateX(50%) rotateY(-90deg) translateX(-50%)"
      default:
        return "rotateX(0deg)"
    }
  }

  const getFirstFaceTransform = () => {
    switch (step) {
      case 0:
        return `rotateY(0deg) translateZ(0.001lh)`
      case 1:
        return `rotateY(90deg) translateZ(0.001lh)`
      case 2:
        return `rotateY(90deg) translateX(-50%) translateZ(0.01lh)`
      case 3:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateZ(0.001lh)"
      case 4:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateZ(0.001lh)"
      case 5:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateZ(0.001lh)"
      case 6:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateZ(0.001lh)"
      case 7:
        return "rotateY(90deg) translateX(-50%) rotateY(-90deg) translateZ(0.001lh)"
      default:
        return "rotateY(0deg) translateZ(0.001lh)"
    }
  }

  const getContainerTransform = () => {
    switch (step) {
      case 0:
        return "translateZ(0)"
      case 1:
        return "translateZ(0)"
      case 2:
        return "translateZ(0)"
      case 3:
        return "translateZ(0)"
      case 4:
        return "translateZ(0)"
      case 5:
        return "translateZ(0)"
      case 6:
        return "translateZ(0lh)"
      case 7:
        return "rotateY(90deg) translateX(50%) rotateY(-90deg)"
      default:
        return "translateZ(0)"
    }
  }

  const getDisplayTransform = () => {
    switch (step) {
      case 0:
        return [" ", " "]
      case 1:
        return ["both faces:", "rotateY(90deg)"]
      case 2:
        return ["both faces:", "rotateY(90deg) translateX(50%)"]
      case 3:
        return ["both faces:", "rotateY(90deg) translateX(50%) rotateY(-90deg)"]
      case 4:
        return [
          "2nd face:",
          "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%)",
        ]
      case 5:
        return [
          "2nd face:",
          "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%) rotateY(-90deg)",
        ]
      case 6:
        return [
          "2nd face:",
          "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%) rotateY(-90deg) translateX(50%)",
        ]
      case 7:
        return ["container:", "rotateY(90deg) translateX(50%) rotateY(-90deg)"]
      default:
        return [" ", " "]
    }
  }

  return (
    <div className="relative w-full h-full flex flex-col items-center justify-center bg-background">
      <div className="flex flex-col items-center max-w-2xl">
        <motion.div
          className="text-9xl transform-3d perspective-1000"
          style={{ transform: "rotateX(-25deg) rotateY(-45deg)" }}
        >
          {/* Axes helper */}
          <div className="absolute top-[150%] left-1/2 -translate-x-1/2 -translate-y-1/2 transform-3d">
            <AxisHelper axisLength={200} />
          </div>

          <motion.div
            className="transform-3d relative z-20 backface-hidden"
            animate={{ transform: getContainerTransform() }}
            transition={{
              ease: "easeInOut",
              duration: 1,
            }}
          >
            {/* Front face */}
            <motion.div
              className="border border-foreground h-[1lh] dark:bg-neutral-800 opacity-80 bg-gray-200 text-foreground"
              animate={{ transform: getFirstFaceTransform() }}
              transition={{
                ease: "easeInOut",
                duration: 1,
              }}
            >
              A
            </motion.div>

            {/* Second face */}
            <motion.div
              className="absolute top-0 left-0 border border-foreground opacity-60 h-[1lh] bg-gray-200 dark:bg-neutral-800 text-foreground"
              animate={{ transform: getSecondFaceTransform() }}
              transition={{
                ease: "easeInOut",
                duration: 1,
              }}
            >
              A
            </motion.div>
          </motion.div>
        </motion.div>

        {/* Transform display */}
        <div className="absolute bottom-1/6 flex items-center h-[1lh] w-full px-16 sm:px-0 sm:w-64">
          <div className="flex w-1/3  min-w-12 min-h-[1lh]">
            <AnimatePresence mode="wait" initial={false}>
              <motion.span
                key={getDisplayTransform()[0]}
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -10 }}
                transition={{ duration: 0.2, delay: 0, ease: "easeOut" }}
                className="font-overusedGrotesk text-muted-foreground  w-full text-end"
              >
                {getDisplayTransform()[0]}
              </motion.span>
            </AnimatePresence>
          </div>
          <div className="flex w-2/3 min-w-12 min-h-[1lh]">
            <AnimatePresence mode="wait">
              <motion.span
                key={getDisplayTransform()[1]}
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -10 }}
                transition={{ duration: 0.2, delay: 0, ease: "easeOut" }}
                className="pl-8 font-mono text-xs text-muted-foreground  pt-1 w-full "
              >
                {getDisplayTransform()[1]}
              </motion.span>
            </AnimatePresence>
          </div>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/letter-3d-swap-explanation-top-demo.tsx
파일 저장

/*
 * If you're reading this file, you're probably looking for the explanation demo.
 * Keep in my mind this demo contains some extra transforms that are only needed
 * for making the animation prettier, and not necessary for the actual functionality.
 * There is a lot of messy code here, so I advise you not to try learn from it.
 * Please refer to the actual documentation for more details.
 */
"use client"

import { useEffect, useState } from "react"
import { AnimatePresence, motion } from "motion/react"

interface AxisHelperProps {
  axisLength: number
}

export const AxisHelper = ({ axisLength }: AxisHelperProps) => {
  // Arrowhead size
  const arrowSize = 12

  return (
    <div
      className="pointer-events-none transform-3d"
      style={{
        transform: "translateY(-100px) translateZ(-100px) translateX(-100px)",
      }}
    >
      {/* X axis (red, right) */}
      <svg
        width={axisLength + arrowSize}
        height={arrowSize * 2}
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          transform: `translateY(-${arrowSize}px)`,
        }}
      >
        <line
          x1={0}
          y1={arrowSize}
          x2={axisLength}
          y2={arrowSize}
          stroke="currentColor"
          strokeOpacity="0.6"
          strokeWidth={2}
          strokeDasharray="6,4"
        />
        <polygon
          points={`
              ${axisLength},${arrowSize}
              ${axisLength - arrowSize},${arrowSize - 5}
              ${axisLength - arrowSize},${arrowSize + 5}
            `}
          fill="currentColor"
        />
      </svg>

      {/* Y axis (green, up) */}
      <svg
        width={arrowSize * 10}
        height={axisLength + arrowSize}
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          transform: `translateX(-${arrowSize}px) translateY(-${axisLength}px)`,
        }}
      >
        <line
          x1={arrowSize}
          y1={axisLength}
          x2={arrowSize}
          y2={0}
          stroke="currentColor"
          strokeOpacity="0.6"
          strokeWidth={2}
          strokeDasharray="6,4"
        />
        <polygon
          points={`
              ${arrowSize},0
              ${arrowSize - 5},${arrowSize}
              ${arrowSize + 5},${arrowSize}
            `}
          fill="currentColor"
        />
      </svg>

      <svg
        width={arrowSize * 2}
        height={axisLength + arrowSize}
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          transform: `translateX(-${arrowSize * 1.8}px) translateY(-${axisLength}px) rotateX(90deg) rotateY(90deg) translateX(${axisLength / 2}px) rotateX(180deg) translateY(-${axisLength / 2 - arrowSize * 1}px)`,
        }}
      >
        <line
          x1={arrowSize}
          y1={axisLength}
          x2={arrowSize}
          y2={0}
          stroke="currentColor"
          strokeOpacity="0.6"
          strokeWidth={2}
          strokeDasharray="6,4"
        />
        <polygon
          points={`
              ${arrowSize},0
              ${arrowSize - 5},${arrowSize}
              ${arrowSize + 5},${arrowSize}
            `}
          fill="currentColor"
        />
      </svg>
    </div>
  )
}
export default function Preview() {
  const [step, setStep] = useState(0)
  const totalSteps = 5

  useEffect(() => {
    const interval = setInterval(
      () => {
        setStep((prev) => {
          if (prev === totalSteps - 1) {
            // Wait longer on the last step
            return 0
          }
          return prev + 1
        })
      },
      step === totalSteps - 1 ? 5000 : 3000
    ) // 4 seconds for last step, 2 seconds for others

    return () => clearInterval(interval)
  }, [step])

  const getSecondFaceTransform = () => {
    switch (step) {
      case 0:
        return `rotateX(0deg)`
      case 1:
        return `rotateX(0deg)`
      case 2:
        return "rotateX(90deg)"
      case 3:
        return "rotateX(90deg) translateZ(0.5lh)"
      case 4:
        return "rotateX(90deg) translateZ(0.5lh)"
      default:
        return "rotateX(0deg)"
    }
  }

  const getFirstFaceTransform = () => {
    switch (step) {
      case 0:
        return "translateZ(0lh)"
      case 1:
        return "translateZ(0.5lh)"
      case 2:
        return "translateZ(0.5lh)"
      case 3:
        return "translateZ(0.5lh)"
      case 4:
        return "translateZ(0.5lh)"
      default:
        return "translateZ(0lh)"
    }
  }

  const getContainerTransform = () => {
    switch (step) {
      case 0:
        return "translateZ(0)"
      case 1:
        return "translateZ(0)"
      case 2:
        return "translateZ(0)"
      case 3:
        return "translateZ(0)"
      case 4:
        return "translateZ(-0.5lh)"
      default:
        return "translateZ(0)"
    }
  }

  const getDisplayTransform = () => {
    switch (step) {
      case 0:
        return [" ", " "]
      case 1:
        return ["1st face:", "translateZ(0.5lh)"]
      case 2:
        return ["2nd face:", "rotateX(90deg)"]
      case 3:
        return ["2nd face:", "rotateX(90deg) translateZ(0.5lh)"]
      case 4:
        return ["container:", "translateZ(-0.5lh)"]
      default:
        return [" ", " "]
    }
  }

  return (
    <div className="relative w-full h-full flex flex-col items-center justify-center bg-background">
      <div className="flex flex-col items-center max-w-2xl">
        <motion.div
          className="text-9xl transform-3d perspective-1000"
          style={{ transform: "rotateX(-25deg) rotateY(-45deg)" }}
        >
          {/* Axes helper */}
          <div className="absolute top-[150%] left-1/2 -translate-x-1/2 -translate-y-1/2 transform-3d">
            <AxisHelper axisLength={200} />
          </div>

          <motion.div
            className="transform-3d relative z-20 backface-hidden"
            animate={{ transform: getContainerTransform() }}
            transition={{
              ease: "easeInOut",
              duration: 1,
            }}
          >
            {/* Front face */}
            <motion.div
              className="border border-foreground h-[1lh] dark:bg-neutral-800 opacity-80 bg-gray-200 text-foreground"
              animate={{ transform: getFirstFaceTransform() }}
              transition={{
                ease: "easeInOut",
                duration: 1,
              }}
            >
              A
            </motion.div>

            {/* Second face */}
            <motion.div
              className="absolute top-0 left-0 border border-foreground opacity-60 h-[1lh] bg-gray-200 dark:bg-neutral-800 text-foreground"
              animate={{ transform: getSecondFaceTransform() }}
              transition={{
                ease: "easeInOut",
                duration: 1,
              }}
            >
              A
            </motion.div>
          </motion.div>
        </motion.div>

        {/* Transform display */}
        <div className="absolute bottom-1/6 flex items-center h-[1lh] w-full px-16 sm:px-0 sm:w-64">
          <div className="flex w-1/3  min-w-12 min-h-[1lh]">
            <AnimatePresence mode="wait" initial={false}>
              <motion.span
                key={getDisplayTransform()[0]}
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -10 }}
                transition={{ duration: 0.2, delay: 0, ease: "easeOut" }}
                className="font-overusedGrotesk text-muted-foreground  w-full text-end"
              >
                {getDisplayTransform()[0]}
              </motion.span>
            </AnimatePresence>
          </div>
          <div className="flex w-2/3 min-w-12 min-h-[1lh]">
            <AnimatePresence mode="wait">
              <motion.span
                key={getDisplayTransform()[1]}
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -10 }}
                transition={{ duration: 0.2, delay: 0, ease: "easeOut" }}
                className="pl-8 font-mono w-full text-muted-foreground  "
              >
                {getDisplayTransform()[1]}
              </motion.span>
            </AnimatePresence>
          </div>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/letter-3d-swap-rotate-top-explainer.tsx
파일 저장

"use client"

import { useState } from "react"
import Letter3DSwap from "@/fancy/components/text/letter-3d-swap"

export default function Preview() {
  const [debug, setDebug] = useState(false)

  return (
    <div className="relative w-full h-full flex flex-col items-center justify-center p-8 bg-white ">
      <button
        className="absolute top-4 left-4 px-2 py-1 bg-white text-black rounded-md border text-[8px] cursor-pointer hover:bg-muted"
        onClick={() => setDebug(!debug)}
      >
        Debug: {debug ? "On" : "Off"}
      </button>
      <div className="flex flex-col items-center max-w-2xl font-cotham">
        <Letter3DSwap 
          mainClassName="text-7xl bg-white lowercase"
          frontFaceClassName={`bg-white ${debug ? 'border' : ''} text-black`}
          secondFaceClassName={`bg-white ${debug ? 'border' : ''} text-black`}
          rotateDirection="left"
          staggerDuration={0.03}
          staggerFrom="first"
          transition={{ type: "spring", damping: 25, stiffness: debug ? 50 : 160 }}
        >
          SET YOUR MIND TO IT
        </Letter3DSwap>
      </div>
    </div>
  )
}
src/fancy/examples/text/letter-3d-swap-stagger-demo.tsx
파일 저장

"use client"

import { useState } from "react"
import Letter3DSwap from "@/fancy/components/text/letter-3d-swap"

export default function Preview() {
  const [debug, setDebug] = useState(false)
  const sharedProps = {
    mainClassName: "text-lg sm:text-3xl md:text-4xl bg-background lowercase text-foreground",
    frontFaceClassName: `bg-background ${debug ? 'border' : ''}`,
    secondFaceClassName: `bg-background ${debug ? 'border' : ''}`,
    staggerDuration: 0.02,
    transition: { type: "spring" as const, damping: 25, stiffness: debug ? 50 : 160 }
  }

  return (
    <div className="relative w-full h-full flex flex-col items-center justify-center p-4 sm:p-6 md:p-8 bg-background gap-4 sm:gap-6 md:gap-8">
      <button
        className="absolute top-4 left-4 px-2 py-1 bg-background text-foreground rounded-md border text-[8px] cursor-pointer hover:bg-muted"
        onClick={() => setDebug(!debug)}
      >
        Debug: {debug ? "On" : "Off"}
      </button>

      <div className="flex flex-col items-center gap-4 sm:gap-6 md:gap-8 max-w-xs sm:max-w-xl md:max-w-2xl font-cotham">
        <Letter3DSwap 
          rotateDirection="left"
          staggerFrom="first"
          {...sharedProps}
        >
          Rotate Left, Stagger First
        </Letter3DSwap>

        <Letter3DSwap 
          rotateDirection="right"
          staggerFrom="last"
          {...sharedProps}
        >
          Rotate Right, Stagger Last
        </Letter3DSwap>

        <Letter3DSwap 
          rotateDirection="top"
          staggerFrom="center"
          {...sharedProps}
        >
          Rotate Top, Stagger Center
        </Letter3DSwap>

        <Letter3DSwap 
          rotateDirection="bottom"
          staggerFrom="random"
          {...sharedProps}
        >
          Rotate Bottom, Stagger Random
        </Letter3DSwap>
      </div>
    </div>
  )
}
src/fancy/examples/text/letter-swap-demo-line.tsx
파일 저장

import LetterSwapForward from "@/fancy/components/text/letter-swap-forward-anim"

export default function Preview() {
  return (
    <div className="w-full h-full text-3xl flex flex-row gap-12 items-center justify-center font-calendas bg-primary-blue">
      <div className="items-center justify-center grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-12  text-white">
        <LetterSwapForward label="oh, wow!" staggerDuration={0} />
        <LetterSwapForward label="nice!" staggerDuration={0} reverse={false} />
      </div>
    </div>
  )
}
src/fancy/examples/text/letter-swap-demo-stagger.tsx
파일 저장

import LetterSwapForward from "@/fancy/components/text/letter-swap-forward-anim"

export default function Preview() {
  return (
    <div className="w-full h-full text-3xl flex md:flex-row flex-col items-center justify-center font-calendas gap-x-12 gap-y-4 bg-white  text-primary-blue">
      <LetterSwapForward label="First" staggerFrom={"first"} />
      <LetterSwapForward label="Center" staggerFrom={"center"} className="" />
      <LetterSwapForward label="Last" staggerFrom={"last"} />
    </div>
  )
}
src/fancy/examples/text/letter-swap-demo.tsx
파일 저장

import LetterSwapForward from "@/fancy/components/text/letter-swap-forward-anim"
import LetterSwapPingPong from "@/fancy/components/text/letter-swap-pingpong-anim"

export default function Preview() {
  return (
    <div className="w-full h-full rounded-lg bg-white text-xl md:text-3xl  flex flex-col items-center justify-center font-calendas">
      <div className=" p-12 text-primary-blue rounded-xl align-text-top  gap-y-1 md:gap-y-2 flex flex-col">
        <LetterSwapForward
          label="Hover me chief!"
          reverse={true}
          className="italic"
        />
        <LetterSwapForward
          label="{awesome}"
          reverse={false}
          className="font-bold"
        />
        <LetterSwapForward
          label="Good day!"
          staggerFrom={"center"}
          className="mono"
        />
        <LetterSwapPingPong
          label="More text?"
          staggerFrom={"center"}
          reverse={false}
          className="font-overused-grotesk font-bold"
        />
        <LetterSwapPingPong label="oh, seriously?!" staggerFrom={"last"} />
      </div>
    </div>
  )
}
src/fancy/examples/text/random-letter-swap-demo.tsx
파일 저장

import RandomLetterSwapForward from "@/fancy/components/text/random-letter-swap-forward-anim"
import RandomLetterSwapPingPong from "@/fancy/components/text/random-letter-swap-pingpong-anim"

export default function Preview() {
  return (
    <div className="w-full h-full rounded-lg bg-white text-3xl md:text-5xl flex flex-col items-center justify-center font-overused-grotesk">
      <div className="h-full text-red-500 rounded-xl py-12  align-text-center gap-y-1 md:gap-y-2 flex flex-col justify-center items-center">
        <RandomLetterSwapForward
          label="Right here!"
          reverse={true}
          className=""
        />
        <RandomLetterSwapForward
          label="Right now!"
          reverse={false}
          className="font-bold italic px-4"
        />
        <RandomLetterSwapPingPong label="Right here!" className="" />
        <RandomLetterSwapPingPong
          label="Right now!"
          reverse={false}
          className=" font-bold"
        />
      </div>
    </div>
  )
}
src/fancy/examples/text/scramble-hover-demo.tsx
파일 저장

"use client"

import { motion } from "motion/react"

import ScrambleHover from "@/fancy/components/text/scramble-hover"

export default function Preview() {
  const models = [
    "Llama 3.1 405B Instruct Turbo",
    "Llama 3.2 3B Instruct Turbo",
    "Gemma 2 27B",
    "Mistral 7B Instruct v0.3",
    "Mixtral 8x7B Instruct",
    "DeepSeek LLM Chat 67B",
    "Qwen 2.5 72B Instruct Turbo",
    "WizardLM 2 8x22B",
    "Nous Hermes 2 Mixtral",
    "StripedHyena Nous 7B",
    "DBRX Instruct",
    "MythoMax L2 13B",
    "SOLAR 10.7B Instruct",
    "Gemma 2B Instruct",
  ]

  return (
    <div className="w-full h-full flex flex-col  justify-center items-end bg-white text-foreground dark:text-muted font-normal overflow-hidden py-20 px-8 sm:px-16 md:px-24 lg:px-32 text-right text-sm sm:text-lg md:text-xl">
      {models.map((model, index) => (
        <motion.div
          layout
          key={model}
          animate={{ opacity: [0, 1, 1], y: [10, 10, 0] }}
          transition={{
            duration: 0.1,
            ease: "circInOut",
            delay: index * 0.05 + 0.5,
            times: [0, 0.2, 1],
          }}
        >
          <ScrambleHover
            text={model}
            scrambleSpeed={50}
            maxIterations={8}
            useOriginalCharsOnly={true}
            className="cursor-pointer"
          />
        </motion.div>
      ))}
    </div>
  )
}
src/fancy/examples/text/scramble-hover-diff-class-demo.tsx
파일 저장

import ScrambleHover from "@/fancy/components/text/scramble-hover"

export default function Preview() {
  return (
    <div className="w-full h-full flex text-4xl justify-center items-center bg-white text-foreground dark:text-muted  font-normal overflow-hidden p-24 space-y-2">
      <ScrambleHover
        text={"special symbols"}
        scrambleSpeed={50}
        maxIterations={8}
        useOriginalCharsOnly={false}
        className="cursor-pointer text-4xl"
        characters="čüỳĦØ↋⒬¢⏧⏛⏄⎄*¿"
        scrambledClassName="font-notoSansSymbols text-3xl cursor-pointer"
      />
    </div>
  )
}
src/fancy/examples/text/scramble-hover-new-chars-demo.tsx
파일 저장

import ScrambleHover from "@/fancy/components/text/scramble-hover"

export default function Preview() {
  return (
    <div className="w-full h-full  text-xl sm:text-3xl md:text-5xl bg-white text-foreground dark:text-muted font-normal overflow-hidden p-12 sm:p-20 flex flex-col md:p-24 space-y-2 space-x-6">
      <ScrambleHover
        text={"original characters"}
        scrambleSpeed={50}
        maxIterations={8}
        useOriginalCharsOnly={true}
        className="cursor-pointer"
      />
      <ScrambleHover
        text={"new characters"}
        scrambleSpeed={50}
        maxIterations={8}
        useOriginalCharsOnly={false}
        className="cursor-pointer"
        characters="abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+-=[]{}|;':\,./<>?"
      />
    </div>
  )
}
src/fancy/examples/text/scramble-hover-sequential-demo.tsx
파일 저장

import ScrambleHover from "@/fancy/components/text/scramble-hover"

export default function Preview() {
  return (
    <div className="w-full h-full text-sm sm:text-xl md:text-2xl justify-center items-center font-normal text-light overflow-hidden p-12 sm:p-20 flex flex-col md:p-24 space-y-20 bg-black text-white">
      <div className="text-left w-full">
        <ScrambleHover
          text={"from the start"}
          scrambleSpeed={40}
          sequential={true}
          revealDirection="start"
          useOriginalCharsOnly={false}
          className="font-azeret-mono"
          characters="abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+-=[]{}|;':\,./<>?"
        />
      </div>
      <div className="text-center w-full">
        <ScrambleHover
          text={"from the center"}
          scrambleSpeed={40}
          sequential={true}
          revealDirection="center"
          useOriginalCharsOnly={false}
          className="font-azeret-mono"
          characters="abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+-=[]{}|;':\,./<>?"
        />
      </div>
      <div className="text-right w-full">
        <ScrambleHover
          text={"from the end"}
          scrambleSpeed={40}
          sequential={true}
          revealDirection="end"
          useOriginalCharsOnly={false}
          className="font-azeret-mono"
          characters="abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+-=[]{}|;':\,./<>?"
        />
      </div>
    </div>
  )
}
src/fancy/examples/text/scramble-in-demo.tsx
파일 저장

import { useEffect, useRef } from "react"

import ScrambleIn, {
  ScrambleInHandle,
} from "@/fancy/components/text/scramble-in"

export default function Preview() {
  const titles = [
    "1. One More Time (featuring Romanthony) - 5:20",
    "2. Aerodynamic - 3:27",
    "3. Digital Love - 4:58",
    "4. Harder, Better, Faster, Stronger - 3:45",
    "5. Crescendolls - 3:31",
    "6. Nightvision - 1:44",
    "7. Superheroes - 3:57",
    "8. High Life - 3:22",
    "9. Something About Us - 3:51",
    "10. Voyager - 3:47",
    "11. Veridis Quo - 5:44",
    "12. Short Circuit - 3:26",
    "13. Face to Face (featuring Todd Edwards) - 3:58",
    "14. Too Long (featuring Romanthony) - 10:00",
  ]

  const scrambleRefs = useRef<(ScrambleInHandle | null)[]>([])

  useEffect(() => {
    titles.forEach((_, index) => {
      const delay = index * 50
      setTimeout(() => {
        scrambleRefs.current[index]?.start()
      }, delay)
    })
  }, [])

  return (
    <div className="w-full h-full flex flex-col text-sm md:text-lg lg:text-lg xl:text-xl justify-start items-start bg-white text-foreground dark:text-muted font-normal overflow-hidden py-16 px-8 sm:px-16 md:px-20 lg:px-24 text-center">
      {titles.map((model, index) => (
        <ScrambleIn
          key={index}
          ref={(el) => {
            scrambleRefs.current[index] = el
          }}
          text={model}
          scrambleSpeed={25}
          scrambledLetterCount={5}
          autoStart={false}
        />
      ))}
    </div>
  )
}
src/fancy/examples/text/scroll-and-swap-text-demo.tsx
파일 저장

"use client"

import { useEffect, useRef } from "react"
import Lenis from "lenis"

import ScrollAndSwapText from "@/fancy/components/text/scroll-and-swap-text"

// Generate an array of imaginary names
const names = [
  "Alexandra Rodriguez",
  "Benjamin Chen",
  "Catherine Williams",
  "David Martinez",
  "Elena Petrov",
  "Francesco Rossi",
  "Gabriela Santos",
  "Henrik Larsson",
  "Isabella Thompson",
  "James Anderson",
  "Katarina Novak",
  "Leonardo Silva",
  "Maria Gonzalez",
  "Nikolai Volkov",
  "Olivia Johnson",
  "Pablo Hernandez",
  "Qiana Washington",
  "Ricardo Lopez",
  "Sophia Kim",
  "Thomas Mueller",
  "Ursula Schmidt",
  "Viktor Petersen",
  "Wen Li",
  "Xavier Dubois",
  "Yasmin Hassan",
  "Zachary Brown",
  "Amelia Davis",
  "Bruno Costa",
  "Clara Johansson",
  "Diego Morales",
  "Evelyn Taylor",
  "Felix Wagner",
  "Grace Wilson",
  "Hugo Andersen",
  "Iris Nakamura",
  "Julian Beck",
  "Kira Popovic",
  "Lucas Garcia",
  "Maya Patel",
  "Nathan Clark",
  "Ophelia Martin",
  "Pietro Romano",
  "Quinn O'Brien",
  "Rosa Fernandez",
  "Sebastian Lee",
  "Tara Mitchell",
  "Ulrich Weber",
  "Valentina Rosso",
  "William Jones",
  "Xiomara Reyes",
  "Yuki Tanaka",
  "Zara Ahmed",
  "Andre Leclerc",
  "Beatrice Hall",
  "Carlos Mendoza",
  "Delphine Moreau",
  "Emilio Bianchi",
  "Fiona Murphy",
  "Giovanni Conti",
  "Helena Svensson",
  "Ivan Dimitrov",
  "Jasmine Green",
  "Kai Nielsen",
  "Luna Torres",
  "Marco Esposito",
  "Nadia Kozlov",
  "Oscar Lindberg",
  "Penelope White",
  "Quincy Adams",
  "Rafael Vargas",
  "Stella Jackson",
  "Theo Van Der Berg",
  "Uma Sharma",
  "Vincenzo Ferrari",
  "Willow Parker",
  "Ximena Castillo",
  "Yolanda King",
  "Zander Cooper",
  "Aria Blackwood",
  "Bastien Dubois",
  "Camille Laurent",
  "Dante Ricci",
  "Estelle Moreau",
  "Fabio Santos",
  "Gemma Wright",
  "Hector Vega",
  "Ingrid Hansen",
  "Javier Ruiz",
  "Kaia Storm",
  "Liam O'Connor",
  "Mila Petrov",
  "Noah Fischer",
  "Octavia Bell",
  "Phoenix Rivera",
  "Quentin Gray",
  "Ruby Anderson",
  "Sage Thompson",
  "Tobias Klein",
  "Unity Cross",
  "Vera Kozlova",
  "Wade Turner",
  "Xara Moon",
  "York Sterling",
  "Zoe Martinez",
  "Atlas Kane",
  "Brielle Fox",
  "Caspian Reed",
  "Dara Singh",
  "Eden Blake",
  "Falcon Knight",
  "Gaia Stone",
  "Harbor Wells",
  "Indigo Vale",
  "Juno Pierce",
  "Knox Rivers",
]

export default function Preview() {
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!containerRef.current) return

    const lenis = new Lenis({
      autoRaf: true,
      wrapper: containerRef.current,
      duration: 3,
      orientation: "vertical",
      gestureOrientation: "vertical",
      smoothWheel: true,
      touchMultiplier: 2,
    })

    return () => {
      lenis.destroy()
    }
  }, [])

  return (
    <div
      className="w-full h-full rounded-lg items-center justify-start font-overused-grotesk p-4 overflow-auto overscroll-auto bg-blue-500 text-white relative"
      ref={containerRef}
    >
      <div className="min-h-[200vh] flex justify-center items-start pt-96 uppercase relative">
        <p className="absolute top-4 left-4 font-bold text-xl">
          SCROLL SLOWLY
        </p>
        <div className="flex md:text-4xl sm:text-2xl text-3xl lg:text-4xl xl:text-5xl justify-center items-center flex-col leading-none -space-y-0">
          {names.map((name, index) => (
            <ScrollAndSwapText
              key={index}
              offset={[`0 0.2`, `0 0.8`]}
              className="font-bold leading-tighter"
              containerRef={containerRef}
            >
              {name}
            </ScrollAndSwapText>
          ))}
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-along-path-auto-demo.tsx
파일 저장

import React, { useRef } from "react"

import AnimatedPathText from "@/fancy/components/text/text-along-path"

export default function TextAlongPathAutoDemo() {
  const containerRef = useRef<HTMLDivElement>(null)

  const paths = [
    "M1 248C214 -47 582 158 679 -39",
    "M1 208C214 -87 582 118 679 -79",
    "M1 168C214 -127 582 78 679 -119",
  ]

  const texts = [
    `PARIS • LONDON • BERLIN • ROME • BARCELONA • MADRID • VIENNA • PRAGUE • AMSTERDAM • STOCKHOLM`,
    `BUDAPEST • COPENHAGEN • OSLO • HELSINKI • MILAN • MUNICH • VENICE • MADRID • VIENNA • PRAGUE`,
    `PARIS • BERLIN • ROME • BARCELONA • MADRID • VIENNA • PRAGUE • AMSTERDAM`,
  ]

  return (
    <div
      className="w-full h-full overflow-hidden relative bg-white"
      ref={containerRef}
    >
      <div className="absolute w-full h-full flex flex-col">
        {paths.map((path, i) => (
          <AnimatedPathText
            key={`auto-path-${i}`}
            path={path}
            pathId={`auto-path-${i}`}
            svgClassName={`absolute -left-[100px] top-1/3 w-[calc(100%+200px)] h-full`}
            viewBox="0 0 680 250"
            text={texts[i]}
            textClassName={`text font-thin text-gray-800`}
            animationType="auto"
            duration={i * 0.5 + 5}
            textAnchor="start"
          />
        ))}
      </div>
    </div>
  )
}
src/fancy/examples/text/text-along-path-circle-demo.tsx
파일 저장

import { cn } from "@/lib/utils"
import AnimatedPathText from "@/fancy/components/text/text-along-path"

export default function Preview() {
  const circlePath =
    "M 100 100 m -50, 0 a 50,50 0 1,1 100,0 a 50,50 0 1,1 -100,0"

  return (
    <div className="w-full h-full flex justify-center items-center relative ">
      {[0, 90, 180, 270].map((rotation, i) => (
        <AnimatedPathText
          key={rotation}
          path={circlePath}
          pathId={`circle-path-${i}`}
          svgClassName={cn(
            "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full ",
            {
              "rotate-0": rotation === 0,
              "rotate-90": rotation === 90,
              "rotate-180": rotation === 180,
              "-rotate-90": rotation === 270,
            }
          )}
          easingFunction={{
            calcMode: "spline",
            keyTimes: "0;1",
            keySplines: "0.762 0.002 0.253 0.999",
          }}
          viewBox="0 0 200 200"
          text="loading"
          textClassName="text-[15px]"
          duration={2.5}
          textAnchor="start"
        />
      ))}
    </div>
  )
}
src/fancy/examples/text/text-along-path-demo.tsx
파일 저장

import { useCallback, useState } from "react"
import { AnimatePresence, motion } from "motion/react"

import { Button } from "@/components/ui/button"
import AnimatedPathText from "@/fancy/components/text/text-along-path"

export default function Preview() {
  // Rounded rectangle path
  const rectPath =
    "M 20,20 L 180,20 A 20,20 0 0,1 200,40 L 200,160 A 20,20 0 0,1 180,180 L 20,180 A 20,20 0 0,1 0,160 L 0,40 A 20,20 0 0,1 20,20"
  const [buttonState, setButtonState] = useState<
    "idle" | "loading" | "success"
  >("idle")
  const [email, setEmail] = useState("")

  const buttonCopy = {
    idle: "Subscribe",
    loading: (
      <motion.div className="h-2 w-2 sm:h-4 sm:w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
    ),
    success: "Done ✓",
  } as const

  const handleSubmit = useCallback(() => {
    if (buttonState === "success") return

    setButtonState("loading")

    setTimeout(() => {
      setButtonState("success")
    }, 1750)

    setTimeout(() => {
      setButtonState("idle")
      setEmail("")
    }, 3500)
  }, [buttonState])

  return (
    <div className="w-full h-full flex justify-center items-center text-primary-blue relative bg-white">
      <AnimatedPathText
        path={rectPath}
        svgClassName="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 py-2 sm:py-8"
        viewBox="-20 10 240 180"
        text="JOIN THE WAITLIST ✉ JOIN THE WAITLIST ✉ JOIN THE WAITLIST ✉ JOIN THE WAITLIST ✉ JOIN THE WAITLIST ✉ "
        textClassName="text-[10.6px] lowercase font-azeret-mono text-primary-blue"
        duration={20}
        preserveAspectRatio="none"
        textAnchor="start"
      />

      {/* This is just fluff for the demo */}
      <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-56 sm:w-80 p-6 ">
        <div className="space-y-2">
          <input
            type="email"
            placeholder="Enter your email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 sm:px-4 sm:py-2 border border-primary-blue focus:outline-hidden focus:ring-primary-blue/50 font-azeret-mono text-xs sm:text-base placeholder:text-primary-blue rounded-lg bg-white"
          />
          <Button
            type="submit"
            onClick={handleSubmit}
            disabled={buttonState === "loading"}
            className="w-full px-3 py-2 h-9 sm:h-11 sm:px-8 sm:py-2 bg-primary-blue text-white hover:bg-primary-blue/90 transition-colors font-azeret-mono text-xs sm:text-base rounded-lg"
          >
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                initial={{ opacity: 0, y: -25 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 25 }}
                key={buttonState}
              >
                {buttonCopy[buttonState]}
              </motion.span>
            </AnimatePresence>
          </Button>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-along-path-scroll-demo.tsx
파일 저장

import { useRef } from "react"

import AnimatedPathText from "@/fancy/components/text/text-along-path"

export default function Preview() {
  const container = useRef<HTMLDivElement>(null)

  const paths = [
    "M1 254C177 219 61 -64 269 15C477 94 332 285 214 348C96 411 155 546 331 486C507 426 410 267 667 215C872.6 173.4 951.333 264.333 965 315",
    "M1 214C177 179 61 -104 269 -25C477 54 332 245 214 308C96 371 155 506 331 446C507 386 410 227 667 175C872.6 133.4 951.333 224.333 965 275",
    "M1 294C177 259 61 -24 269 55C477 134 332 325 214 388C96 451 155 586 331 526C507 466 410 307 667 255C872.6 213.4 951.333 304.333 965 355",
    "M1 174C177 139 61 -144 269 -65C477 14 332 205 214 268C96 331 155 466 331 406C507 346 410 187 667 135C872.6 93.4 951.333 184.333 965 235",
    "M1 334C177 299 61 16 269 95C477 174 332 365 214 428C96 491 155 626 331 566C507 506 410 347 667 295C872.6 253.4 951.333 344.333 965 395",
    "M1 134C177 99 61 -184 269 -105C477 -26 332 165 214 228C96 291 155 426 331 366C507 306 410 147 667 95C872.6 53.4 951.333 144.333 965 195",
    "M1 374C177 339 61 56 269 135C477 214 332 405 214 468C96 531 155 666 331 606C507 546 410 387 667 335C872.6 293.4 951.333 384.333 965 435",
    "M1 94C177 59 61 -224 269 -145C477 -66 332 125 214 188C96 251 155 386 331 326C507 266 410 107 667 55C872.6 13.4 951.333 104.333 965 155",
  ]

  // Fun text phrases for each path
  const texts = [
    "Information is expanding daily. How to get it out visually is important.",
    "The details are not the details. They make the design.",
    "There's no other product that changes function like the computer.",
    "Innovation is the outcome of a habit, not a random act.",
    "The only important thing about design is how it relates to people.",
    "Good design is obvious. Great design is transparent.",
  ]

  return (
    <div
      className="w-full h-full overflow-auto relative font-calendas"
      ref={container}
    >
      <div className="h-[200%] absolute top-0 left-0 w-full flex flex-col items-center mt-40 text-4xl">
        <p>SCROLL DOWN</p>
      </div>
      <div className="sticky w-full top-0  h-full flex flex-col">
        {paths.map((path, i) => (
          <AnimatedPathText
            key={`path-${i}`}
            path={path}
            // showPath
            scrollContainer={container}
            pathId={`flowing-path-${i}`}
            svgClassName={`absolute -left-[100px] top-0 w-[calc(100%+200px)] h-full`}
            viewBox="0 0 900 600"
            text={texts[i]}
            textClassName={`text-xl font-thin font-calendas`}
            animationType="scroll"
            scrollTransformValues={[-130, 95]}
            textAnchor="start"
          />
        ))}
      </div>
    </div>
  );
}
src/fancy/examples/text/text-cursor-proximity-demo.tsx
파일 저장

"use client"

import { useRef } from "react"

import TextCursorProximity from "@/fancy/components/text/text-cursor-proximity"

const styles = {
  title: {
    filter: {
      from: "blur(0px)",
      to: "blur(8px)",
    }
  },
  details: {
    filter: {
      from: "blur(0px)", 
      to: "blur(4px)",
    }
  }
}

export default function Preview() {
  const containerRef = useRef<HTMLDivElement>(null)

  return (
    <div
      className="w-full h-full flex flex-col items-center justify-center p-6 sm:p-12 md:p-16 lg:p-24 shadow-lg bg-white"
      ref={containerRef}
    >
      <div className="relative min-w-[280px] max-w-[400px] sm:min-w-[350px] h-2/3 sm:h-full overflow-hidden w-full sm:w-4/5 md:w-4/5 lg:w-2/3 justify-between flex-col flex items-start shadow-lg p-4 bg-primary-blue text-white select-none">
        <div className="flex flex-col justify-center uppercase -space-y-2">
          <TextCursorProximity
            className="text-xl will-change-transform sm:text-2xl md:text-3xl lg:text-5xl font-overused-grotesk font-bold"
            styles={styles.title}
            falloff="gaussian"
            radius={100}
            containerRef={containerRef}
          >
            DIGITAL
          </TextCursorProximity>
          <TextCursorProximity
            className="text-xl will-change-transform sm:text-2xl md:text-3xl lg:text-5xl font-overused-grotesk font-bold"
            styles={styles.title}
            falloff="gaussian"
            radius={100}
            containerRef={containerRef}
          >
            WORKSHOP
          </TextCursorProximity>
        </div>

        <div className=" flex w-full justify-between font-medium">
          <div className="flex flex-col w-full leading-tight text-xs sm:text-sm md:text-sm lg:text-base ">
            <TextCursorProximity
              className="text-left"
              styles={styles.details}
              falloff="exponential"
              radius={70}
              containerRef={containerRef}
            >
              LONDON, UK ⟡ 18:30 GMT
            </TextCursorProximity>

            <TextCursorProximity
              className=" text-right"
              styles={styles.details}
              falloff="exponential"
              radius={70}
              containerRef={containerRef}
            >
              123 DIGITAL STREET, EC1A 1BB ⟶
            </TextCursorProximity>

            <TextCursorProximity
              className="text-left"
              styles={styles.details}
              falloff="exponential"
              radius={70}
              containerRef={containerRef}
            >
              +44 20 7123 4567 ⟨⟩ INFO@DIGITAL.WORK
            </TextCursorProximity>

            <TextCursorProximity
              className="text-left"
              styles={styles.details}
              falloff="exponential"
              radius={70}
              containerRef={containerRef}
            >
              @DIGITALWORKSHOP * DIGITAL.WORK®
            </TextCursorProximity>

            <TextCursorProximity
              className="text-right"
              styles={styles.details}
              falloff="exponential"
              radius={70}
              containerRef={containerRef}
            >
              RSVP REQUIRED ⌲ LIMITED SEATS
            </TextCursorProximity>
          </div>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-cursor-proximity-falloff-demo.tsx
파일 저장

"use client"

import { useRef } from "react"

import TextCursorProximity from "@/fancy/components/text/text-cursor-proximity"

export default function Preview() {
  const containerRef = useRef<HTMLDivElement>(null)

  return (
    <div
      className="w-full h-full rounded-lg items-center justify-center font-overused-grotesk p-8 sm:p-16 md:p-20 lg:p-24 bg-white cursor-pointer relative overflow-hidden"
      ref={containerRef}
    >
      {/* this is the important stuff */}
      <div className="w-full h-full items-center justify-center grid text-justify">
        <TextCursorProximity
          className="leading-tight text-primary-blue text-pretty"
          styles={{
            opacity: { from: 0.1, to: 1 },
          }}
          falloff="linear"
          radius={80}
          containerRef={containerRef}
        >
          Just as every problem is novel and different from others. so the grid
          must be conceived afresh every time so as to meet requirements. This
          means that the designer must approach each new problem with an open
          mind and must seek to solve it by analysing it objectively. The
          difficulties of the task are due to the enormous differences in the
          demands made on the designer by the various assignments he receives. A
          small newspaper advertisement does not present the difficulties of
          designing, say, a daily paper with 10 and more columns. a great
          variety of subjects, and an additional advertising section. Such a
          task calls not only for designing talent but also organizing ability
          since the many constantly changing items of information have to be
          arranged in a logical order and their priorities reflected in
          appropriate typography.
        </TextCursorProximity>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-highlighter-demo.tsx
파일 저장

"use client"

import { useEffect, useRef } from "react"
import Lenis from "lenis"

import { TextHighlighter } from "@/fancy/components/text/text-highlighter"
import { Transition } from "motion"

export default function TextHighlighterDemo() {
  const containerRef = useRef<HTMLDivElement | null>(null)

  const transition = { type: "spring", duration: 1, delay: 0.4, bounce: 0 }
  const highlightClass = "rounded-[0.3em] px-px"
  const highlightColor = "#F2AD91"
  const inViewOptions = { once: true, initial: true, amount: 0.1 }

  useEffect(() => {
    if (!containerRef.current) return

    const lenis = new Lenis({
      autoRaf: true,
      wrapper: containerRef.current,
      duration: 1.2,
      orientation: "vertical",
      gestureOrientation: "vertical",
      smoothWheel: true,
      touchMultiplier: 2,
    })

    return () => {
      lenis.destroy()
    }
  }, [])

  return (
    <div className="h-full w-full bg-[#fefefe] relative p-0">
      <div className="absolute bottom-0 w-full left-0 h-64 bg-gradient-to-t from-[#fefefe] from-10% via-50% via-[#fefefe]/50 to-transparent pointer-events-none isolate" />

      <div
        className="h-full w-full z-10 bg-[#fefefe] overflow-scroll"
        ref={containerRef}
      >
        <div className="max-w-md mx-auto px-4 mt-40 pb-64 p-0  text-black">
          <h1 className="text-4xl font-medium mb-20 font-calendas tracking-tight">
            Typeface alphabets
          </h1>

          <div className="text leading-normal space-y-4 font-overusedGrotesk ">
            <p className="whitespace-break-spaces">
              The present-day designer has a host of printing types at his
              disposal.{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                Since Gutenberg first invented movable type in 1436-55
              </TextHighlighter>{" "}
              hundreds of different types have been designed and cast in lead.{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                The most recent technical developments
              </TextHighlighter>{" "}
              with computer and photo-typesetting have once again brought new
              faces or variations of old ones on the market.
            </p>

            <p>
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                The choice is up to the designer
              </TextHighlighter>{" "}
              It is left to his feeling for form to use{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                good or poor typefaces
              </TextHighlighter>{" "}
              for his design work. In view of the limited space available, we
              shall refer here to only a few of the outstanding designs of the
              past and the 20th century which have appeared most frequently in
              publications.
            </p>

            <p>
              Knowledge of the quality of a typeface is of the greatest
              importance for the{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                functional, aesthetic and psychological effect
              </TextHighlighter>{" "}
              of printed matter. Again, the typographic design, i. e. the
              correct spaces between letters and words and the length and
              spacing of lines conducive to easy reading, does much to enhance
              the impression created.{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                Today the field is dominated mainly by computer and
                photo-typesetting
              </TextHighlighter>{" "}
              A typical characteristic of these forms of composition is the too
              narrow setting of the letters which makes reading difficult. The
              designer will be well advised to demand the normal spacing between
              letters when ordering photo-typesetting.
            </p>

            <p>
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                By studying the classic designs
              </TextHighlighter>{" "}
              of{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                Garamond, Casion, Bodoni, Walbaum
              </TextHighlighter>{" "}
              and others, the designer can learn what the timeless criteria are
              which produce a refined and artistic typeface that makes for ease
              of reading.
            </p>

            <p>
              The lead type designs of{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                Berthold, Helvetica, Folio, Univers
              </TextHighlighter>{" "}
              etc. produce pleasant and easily legible type areas. The
              typographic rules that apply to the roman typefaces are also valid
              for the sans serifs.
            </p>

            <p>
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                The creators of these type designs
              </TextHighlighter>{" "}
              were extremely intelligent artists with high creative powers. This
              is shown by the fact that for more than four centuries innumerable
              type designers have sought to create new type alphabets but very
              few of these have gained acceptance.{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                An alphabet of Garamond
              </TextHighlighter>{" "}
              for example, is an artistic achievement of the first order. Each
              letter has its own unmistakable face, whether lower or upper case,
              and displays the highest quality of form and originality. Each
              letter has its own personality and makes a marked impact.
            </p>

            <p>
              Every designer who is concerned with typography should take the
              trouble when creating graphic designs to{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                sketch words and sentences by hand
              </TextHighlighter>{" "}
              Many designers take advantage of the Letraset process, which can
              undoubtedly produce a clean draft design that is almost ready for
              press. But a feeling for good letter forms and an attractive
              typeface can be acquired only by constant and careful practice in
              sketching letters.
            </p>

            <p>
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                How the forms of letters can create simultaneously both tension
                and nobility
              </TextHighlighter>{" "}
              and how pleasantly legible lines of type can appear to the eye of
              the reader may be seen from the examples on the following pages.
            </p>

            <p>
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                The Renaissance created midline typography
              </TextHighlighter>{" "}
              which held its position until the 20th century.
            </p>

            <p>
              The new typography differs from the old in that it is the first to
              try to{" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                develop the outward appearance from the function of the text
              </TextHighlighter>
            </p>

            <p>
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                The new typography uses the background
              </TextHighlighter>{" "}
              as an element of design which is on a par with the other elements.
            </p>

            <p>
              Earlier typography (midline typography){" "}
              <TextHighlighter
                className={highlightClass}
                transition={transition as Transition}
                highlightColor={highlightColor}
                useInViewOptions={inViewOptions}
              >
                played an active role against a dead, passive background.
              </TextHighlighter>
            </p>
          </div>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-highlighter-hover-demo.tsx
파일 저장

"use client"

import { TextHighlighter } from "@/fancy/components/text/text-highlighter"

export default function TextHighlighterHoverDemo() {
  return (
    <div className="h-full w-full bg-[#fefefe] flex items-center justify-center">
      <div className="max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl mx-auto px-4 text-black">
        <div className="flex flex-col gap-8 text-lg sm:text-xl md:text-2xl lg:text-3xl text-center">
          <TextHighlighter
            triggerType="hover"
            direction="ltr"
            className="px-2 py-1 cursor-pointer"
            highlightColor="#BBC2E2"
            transition={{ type: "spring", duration: 0.68, bounce: 0 }}
          >
            hover me - left to right
          </TextHighlighter>

          <TextHighlighter
            triggerType="hover"
            direction="rtl"
            className="px-2 py-1 cursor-pointer"
            highlightColor="#BBC2E2"
            transition={{ type: "spring", duration: 0.8, bounce: 0 }}
          >
            hover me - right to left
          </TextHighlighter>

          <TextHighlighter
            triggerType="hover"
            direction="ttb"
            className="px-2 py-1 cursor-pointer"
            highlightColor="#BBC2E2"
            transition={{ type: "spring", duration: 0.8, bounce: 0 }}
          >
            hover me - top to bottom
          </TextHighlighter>

          <TextHighlighter
            triggerType="hover"
            direction="btt"
            className="px-2 py-1 cursor-pointer"
            highlightColor="#BBC2E2"
            transition={{ type: "spring", duration: 0.8, bounce: 0 }}
          >
            hover me - bottom to top
          </TextHighlighter>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-highlighter-ref-demo.tsx
파일 저장

"use client"

import { useRef, useState } from "react"

import {
  TextHighlighter,
  TextHighlighterRef,
} from "@/fancy/components/text/text-highlighter"
import { Transition } from "motion"

export default function TextHighlighterDemo() {
  const containerRef = useRef<HTMLDivElement | null>(null)
  const highlighterRefs = useRef<TextHighlighterRef[]>([])
  const [isHighlighted, setIsHighlighted] = useState(false)

  const transition = { type: "spring", duration: 1, delay: 0, bounce: 0 }
  const highlightClass = "rounded-[0.3em] px-px"
  const highlightColor = "#F7F764"

  const handleHighlight = () => {
    highlighterRefs.current.forEach((ref) => {
      ref.animate()
    })
    setIsHighlighted(true)
  }

  const handleReset = () => {
    highlighterRefs.current.forEach((ref) => {
      ref.reset()
    })
    setIsHighlighted(false)
  }

  return (
    <div className="h-full w-full bg-[#fefefe] relative p-0">
      <div
        className="h-full w-full z-10 bg-[#fefefe] overflow-scroll"
        ref={containerRef}
      >
        <div className="max-w-5xl mx-auto px-12 mt-20 pb-32">
          <div className="grid grid-cols-1 md:grid-cols-2 gap-8 text-xs leading-relaxed">
            <div className="space-y-2">
              <p>
                The present-day designer has a host of printing types at his
                disposal. Since{" "}
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  Gutenberg
                </TextHighlighter>{" "}
                first invented movable type in 1436-55 hundreds of different
                types have been designed and cast in lead. The{" "}
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  most recent technical developments
                </TextHighlighter>{" "}
                with computer and photo-typesetting have once again brought new
                faces or variations of old ones on the market.
              </p>
            </div>

            <div className="space-y-2">
              <p>
                Knowledge of the quality of a typeface is of the greatest
                importance for the{" "}
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  functional, aesthetic and psychological effect
                </TextHighlighter>{" "}
                of printed matter. Again, the typographic design, i.e. the
                correct spaces between letters and words and the length and
                spacing of lines conducive to easy reading, does much to enhance
                the impression created.
              </p>
            </div>

            <div className="space-y-2">
              <p>
                By studying the classic designs of{" "}
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  Garamond, Caslon, Bodoni, Walbaum
                </TextHighlighter>{" "}
                and others, the designer can learn what the timeless criteria
                are which produce a refined and artistic typeface that makes for
                ease of reading.
              </p>
            </div>

            <div className="space-y-2">
              <p>
                The lead type designs of{" "}
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  Berthold, Helvetica, Folio, Univers
                </TextHighlighter>{" "}
                etc. produce pleasant and easily legible type areas. The
                typographic rules that apply to the roman typefaces are also
                valid for the sans serifs.
              </p>
            </div>

            <div className="space-y-2">
              <p>
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  The creators of these type designs
                </TextHighlighter>{" "}
                were extremely intelligent artists with high creative powers.
                This is shown by the fact that for more than four centuries
                innumerable type designers have sought to create new type
                alphabets but very few of these have gained acceptance. An{" "}
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  alphabet of Garamond
                </TextHighlighter>{" "}
                for example, is an artistic achievement of the first order.
              </p>
            </div>

            <div className="space-y-2">
              <p>
                Every designer who is concerned with typography should take the
                trouble when creating graphic designs to{" "}
                <TextHighlighter
                  ref={(el) => {
                    if (el) highlighterRefs.current.push(el)
                  }}
                  triggerType="ref"
                  className={highlightClass}
                  transition={transition as Transition}
                  highlightColor={highlightColor}
                >
                  sketch words and sentences by hand
                </TextHighlighter>
                . Many designers take advantage of the Letraset process, which
                can undoubtedly produce a clean draft design that is almost
                ready for press.
              </p>
            </div>
          </div>
        </div>
      </div>

      <div className="absolute top-4 left-4 flex gap-4">
        <button
          onClick={isHighlighted ? handleReset : handleHighlight}
          className="text-black border border-border px-3 py-1.5 rounded-md bg-transparent text-xs backdrop-blur-lg cursor-pointer hover:bg-muted"
        >
          {isHighlighted ? "Reset" : "Highlight"}
        </button>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-highlighter-scroll-demo.tsx
파일 저장

"use client"

import React, { useEffect, useRef, useState } from "react"
import { motion, Transition, useInView } from "motion/react"

import { TextHighlighter } from "@/fancy/components/text/text-highlighter"

const HIGHLIGHT_COLOR = "hsl(80, 100%, 50%)"

const DEMO_USE_IN_VIEW_OPTIONS = { once: false, initial: false, amount: 0.1 }
const DEMO_TRANSITION = { type: "spring", duration: 1, delay: 0.4, bounce: 0 }

const SECTION_CLASSES =
  "min-w-full h-full snap-start flex items-center justify-center shrink-0"
const CONTAINER_CLASSES =
  "max-w-[240px] sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl mx-auto px-4 sm:px-6"
const PARAGRAPH_CLASSES =
  "text-sm sm:text-base md:text-lg leading-relaxed font-overusedGrotesk mb-3 sm:mb-4 last:mb-0"

function Section({
  children,
  delay = 0,
}: {
  children: React.ReactNode
  delay?: number
}) {
  const ref = useRef(null)
  const isInView = useInView(ref, {
    once: false,
    margin: "-20%",
    amount: 0.5,
  })

  return (
    <section className={SECTION_CLASSES}>
      <div className={CONTAINER_CLASSES}>
        <motion.div
          ref={ref}
          initial={{
            opacity: 0,
            filter: "blur(8px)",
          }}
          animate={
            isInView
              ? { opacity: 1, filter: "blur(0px)" }
              : { opacity: 0.3, filter: "blur(6px)" }
          }
          transition={{
            duration: 0.8,
            delay: isInView ? delay : 0,
            ease: [0.25, 0.1, 0.25, 1],
          }}
          className="space-y-4"
        >
          {children}
        </motion.div>
      </div>
    </section>
  )
}

function Paragraph({ children }: { children: React.ReactNode }) {
  return <p className={PARAGRAPH_CLASSES}>{children}</p>
}

export default function TextHighlighterDemo() {
  const containerRef = useRef<HTMLDivElement>(null)
  const [currentSection, setCurrentSection] = useState(1)
  const [scrollDirection, setScrollDirection] = useState<"ltr" | "rtl">("ltr")

  useEffect(() => {
    const container = containerRef.current
    if (!container) return

    let prevScrollLeft = container.scrollLeft

    const handleScroll = () => {
      const scrollLeft = container.scrollLeft
      const containerWidth = container.clientWidth
      const sectionIndex = Math.round(scrollLeft / containerWidth) + 1
      setCurrentSection(Math.min(5, Math.max(1, sectionIndex)))

      const scrollDiff = scrollLeft - prevScrollLeft
      if (Math.abs(scrollDiff) > 5) {
        setScrollDirection(scrollDiff > 0 ? "ltr" : "rtl")
      }
      prevScrollLeft = scrollLeft
    }

    container.addEventListener("scroll", handleScroll)
    return () => container.removeEventListener("scroll", handleScroll)
  }, [])

  return (
    <div className="h-full w-full bg-[#fff] text-black relative p-0">
      <div className="absolute bottom-8 sm:bottom-12 md:bottom-16 lg:bottom-20 left-1/2 z-20 text-sm sm:text-base -translate-x-1/2 rounded-full border border-black/80 px-2 sm:px-3 pb-0.5 flex items-center justify-center w-8 sm:w-10 tabular-nums">
        <div key={currentSection} className="font-overusedGrotesk">
          {currentSection.toString().padStart(2, "0")}
        </div>
      </div>

      <div
        ref={containerRef}
        className="h-full w-full z-10 bg-[#fff] overflow-x-scroll overflow-y-hidden snap-x snap-mandatory flex mb-4 sm:mb-6"
      >
        <Section>
          <Paragraph>
            <span>Our </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              object detection systems
            </TextHighlighter>
            <span> identify and locate items in real-time. From </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              facial recognition
            </TextHighlighter>
            <span>
              {" "}
              to product identification, we deliver precision at scale.
            </span>
          </Paragraph>

          <Paragraph>
            <span>Whether it's </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              traffic monitoring
            </TextHighlighter>
            <span> for smart cities or </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              inventory management
            </TextHighlighter>
            <span>
              {" "}
              for retail, our AI distinguishes between people, vehicles, and
              objects with unmatched accuracy.
            </span>
          </Paragraph>
        </Section>

        <Section>
          <Paragraph>
            <span>Advanced </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              video analytics
            </TextHighlighter>
            <span> track movement across frames. Our </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              object tracking algorithms
            </TextHighlighter>
            <span>
              {" "}
              power autonomous vehicles and security systems worldwide.
            </span>
          </Paragraph>

          <Paragraph>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              Scene understanding
            </TextHighlighter>
            <span>
              {" "}
              capabilities analyze spatial relationships and context. From{` `}
            </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              sports performance analysis
            </TextHighlighter>
            <span> to </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              surveillance systems
            </TextHighlighter>
            <span>, we make sense of complex visual data.</span>
          </Paragraph>
        </Section>

        <Section>
          <Paragraph>
            <span>Our </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              OCR technology
            </TextHighlighter>
            <span>
              {" "}
              converts printed and handwritten text to digital format instantly.{" "}
            </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              Document automation
            </TextHighlighter>
            <span> streamlines workflows across industries.</span>
          </Paragraph>

          <Paragraph>
            <span>From </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              invoice processing
            </TextHighlighter>
            <span> to </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              accessibility solutions
            </TextHighlighter>
            <span>
              , our text recognition supports multiple languages and formats
              with exceptional accuracy.
            </span>
          </Paragraph>
        </Section>

        <Section>
          <Paragraph>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              3D depth perception
            </TextHighlighter>
            <span> enables precise spatial understanding. Our </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              stereo vision systems
            </TextHighlighter>
            <span>
              {" "}
              power robotic automation and quality control processes.
            </span>
          </Paragraph>

          <Paragraph>
            <span>Advanced </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              augmented reality
            </TextHighlighter>
            <span> and </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              virtual reality applications
            </TextHighlighter>
            <span>
              {" "}
              rely on our depth analysis for immersive, interactive experiences.
            </span>
          </Paragraph>
        </Section>

        <Section>
          <Paragraph>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              Image segmentation
            </TextHighlighter>
            <span>
              {" "}
              separates objects with pixel-perfect precision. Our{` `}
            </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              enhancement algorithms
            </TextHighlighter>
            <span>
              {" "}
              restore clarity and remove noise from any visual content.
            </span>
          </Paragraph>

          <Paragraph>
            <span>Generate </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              synthetic training data
            </TextHighlighter>
            <span> and create </span>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              high-resolution imagery
            </TextHighlighter>
            <span> for machine learning models and creative applications.</span>
          </Paragraph>

          <Paragraph>
            <TextHighlighter
              highlightColor={HIGHLIGHT_COLOR}
              direction={scrollDirection}
              useInViewOptions={DEMO_USE_IN_VIEW_OPTIONS}
              transition={DEMO_TRANSITION as Transition}
            >
              Transform your industry
            </TextHighlighter>
            <span>
              {" "}
              with computer vision that sees, understands, and acts on visual
              information like never before.
            </span>
          </Paragraph>
        </Section>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-rotate-custom-animation-demo.tsx
파일 저장

"use client"

import TextRotate from "@/fancy/components/text/text-rotate"

export default function Preview() {
  return (
    <div className="w-full h-full text-2xl sm:text-3xl md:text-5xl flex flex-col items-center justify-center font-cotham text font-normal overflow-hidden p-12 gap-12 bg-white text-black">
      <TextRotate
        texts={[
          "The problem isn't how to make the world more technological. It's about how to make the world more humane again.",
          "When you use other people's software you live in somebody else's dream.",
        ]}
        mainClassName=" md:leading-10 flex whitespace-pre text-lg sm:text-xl md:text-5xl max-w-xl text-center"
        staggerFrom={"random"}
        animatePresenceMode="wait"
        splitBy="characters"
        initial={[
          { filter: "blur(20px)", opacity: 0 },
        ]}
        animate={[
          { filter: "blur(0px)", opacity: 1 },
        ]}
        exit={[
          { filter: "blur(20px)", opacity: 0 },
        ]}
        loop
        staggerDuration={0.01}
        splitLevelClassName=""
        elementLevelClassName="md:py-[4px]"
        transition={{ ease: [0.909, 0.151, 0.153, 0.86], duration: 1 }}
        rotationInterval={4000}
      />
    </div>
  )
}
src/fancy/examples/text/text-rotate-demo.tsx
파일 저장

"use client"

import { LayoutGroup, motion } from "motion/react"

import TextRotate from "@/fancy/components/text/text-rotate"

export default function Preview() {
  return (
    <div className="w-full h-full text-2xl sm:text-3xl md:text-5xl flex flex-row items-center justify-center font-overused-grotesk bg-white dark:text-muted text-foreground font-light overflow-hidden p-12 sm:p-20 md:p-24">
      <LayoutGroup>
        <motion.p className="flex whitespace-pre" layout>
          <motion.span
            className="pt-0.5 sm:pt-1 md:pt-2"
            layout
            transition={{ type: "spring", damping: 30, stiffness: 400 }}
          >
            Make it{" "}
          </motion.span>
          <TextRotate
            texts={[
              "work!",
              "fancy ✽",
              "right",
              "fast",
              "fun",
              "rock",
              "🕶️🕶️🕶️",
            ]}
            mainClassName="text-white px-2 sm:px-2 md:px-3 bg-primary-red overflow-hidden py-0.5 sm:py-1 md:py-2 justify-center rounded-lg"
            staggerFrom={"last"}
            initial={{ y: "100%" }}
            animate={{ y: 0 }}
            exit={{ y: "-120%" }}
            staggerDuration={0.025}
            splitLevelClassName="overflow-hidden pb-0.5 sm:pb-1 md:pb-1"
            transition={{ type: "spring", damping: 30, stiffness: 400 }}
            rotationInterval={2000}
          />
        </motion.p>
      </LayoutGroup>
    </div>
  )
}
src/fancy/examples/text/text-rotate-mapping-demo.tsx
파일 저장

"use client"

import TextRotate from "@/fancy/components/text/text-rotate"

export default function Preview() {
  return (
    <div className="w-full h-full text-2xl sm:text-3xl md:text-5xl flex flex-col items-center justify-center font-cotham text font-normal overflow-hidden p-12 gap-12 bg-white text-black">
      <TextRotate
        texts={[
          "The problem isn't how to make the world more technological. It's about how to make the world more humane again.",
          "The problem isn't how to make the world more technological. It's about how to make the world more humane again.",
        ]}
        mainClassName="overflow-hidden md:leading-10 flex whitespace-pre text-lg sm:text-xl md:text-5xl max-w-xl text-center"
        staggerFrom={"random"}
        animatePresenceMode="wait"
        splitBy="characters"
        initial={[{ x: "120%" }, { y: "120%" }, { x: "-120%" }, { y: "-120%" }]}
        animate={[{ x: 0 }, { y: 0 }, { x: 0 }, { y: 0 }]}
        exit={[{ x: "-120%" }, { y: "-120%" }, { x: "120%" }, { y: "120%" }]}
        loop
        staggerDuration={0.01}
        splitLevelClassName="overflow-hidden"
        elementLevelClassName="overflow-hidden md:py-[4px]"
        transition={{ ease: [0.909, 0.151, 0.153, 0.86], duration: 1 }}
        rotationInterval={4000}
      />
    </div>
  )
}
src/fancy/examples/text/text-rotate-multiline-demo.tsx
파일 저장

"use client"

import { LayoutGroup, motion } from "motion/react"

import TextRotate from "@/fancy/components/text/text-rotate"

export default function Preview() {
  return (
    <div className="w-full h-full flex flex-col items-start font-overused-grotesk  font-light overflow-hidden p-8 pt-20 sm:pt-16 sm:p-16 md:p-20 bg-white text-base sm:text-xl md:text-2xl leading-tight dark:text-muted text-foreground">
      <LayoutGroup>
        <TextRotate
          texts={[
            "A typeface family is an accomplishment on the order of a novel, a feature film screenplay, a computer language design and implementation, a major musical composition, a monumental sculpture, or other artistic or technical endeavors that consume a year or more of intensive creative effort.",
            "Typography is two-dimensional architecture, based on experience and imagination, and guided by rules and readability. And this is the purpose of typography: The arrangement of design elements within a given structure should allow the reader to easily focus on the message, without slowing down the speed of his reading.",
          ]}
          staggerFrom={"first"}
          staggerDuration={0.01}
          initial={{ opacity: 0, x: 10 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -10 }}
          transition={{ type: "spring", damping: 30, stiffness: 400 }}
          rotationInterval={4000}
          splitBy="words"
        />
        <motion.div
          className="bg-primary-red w-2 h-2 sm:w-3 sm:h-3 rounded-full my-6"
          layout
        />
        <TextRotate
          texts={["Charles Bigelow", "Hermann Zapf"]}
          staggerFrom={"first"}
          staggerDuration={0.025}
          initial={{ opacity: 0, x: 10 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -10 }}
          transition={{ type: "spring", damping: 30, stiffness: 400 }}
          rotationInterval={4000}
          splitBy="characters"
        />
      </LayoutGroup>
    </div>
  )
}
src/fancy/examples/text/text-rotate-scroll-step-demo.tsx
파일 저장

"use client"

import { useEffect, useRef } from "react"
import { exampleImages } from "@/utils/demo-images"
import { useInView } from "motion/react"

import TextRotate, { TextRotateRef } from "@/fancy/components/text/text-rotate"

function Item({
  index,
  image,
  link,
  onInView,
}: {
  index: number
  image: string
  link: string
  onInView: (inView: boolean) => void
}) {
  const ref = useRef<HTMLDivElement>(null)
  const isInView = useInView(ref, {
    margin: "-45% 0px -45% 0px",
  })

  useEffect(() => {
    onInView(isInView)
  }, [isInView, onInView])

  return (
    <section
      ref={ref}
      key={index + 1}
      className="h-full w-1/2 flex justify-center items-center snap-center"
    >
      <div className="w-16 h-16 sm:w-36 sm:h-36 md:w-40 md:h-40">
        <a href={link} target="_blank" rel="noreferrer">
          <img
            src={image}
            alt={`Example ${index + 2}`}
            className="w-full h-full object-cover"
          />
        </a>
      </div>
    </section>
  )
}

export default function Preview() {
  const textRotateRef = useRef<TextRotateRef>(null)

  const handleInView = (index: number, inView: boolean) => {
    console.log(index, inView)
    if (inView && textRotateRef.current) {
      textRotateRef.current.jumpTo(index)
    }
  }

  return (
    <div className="w-full h-full overflow-auto absolute snap-y snap-mandatory">
      <div className="sticky inset-0 h-full w-full flex items-center justify-end bg-white dark:text-muted text-foreground">
        <div className="w-2/3">
          <TextRotate
            ref={textRotateRef}
            texts={[...exampleImages.map((image) => image.author)]}
            mainClassName="text-sm sm:text-3xl md:text-4xl w-full justify-center flex pt-2"
            splitLevelClassName="overflow-hidden pb-2"
            staggerFrom={"first"}
            animatePresenceMode="wait"
            loop={false}
            auto={false}
            staggerDuration={0.005}
            initial={{ opacity: 0, y: 50 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -50 }}
            transition={{ type: "spring", duration: 0.6, bounce: 0 }}
          />
        </div>
      </div>
      <div className="absolute inset-0">
        {exampleImages.slice(1).map((image, index) => (
          <Item
            key={index}
            index={index}
            image={image.url}
            link={image.link}
            onInView={(inView) => handleInView(index, inView)}
          />
        ))}
      </div>
    </div>
  )
}
src/fancy/examples/text/text-rotate-stagger-demo.tsx
파일 저장

"use client"

import { exampleImages } from "@/utils/demo-images"

import TextRotate from "@/fancy/components/text/text-rotate"

export default function Preview() {
  return (
    <div className="w-full h-full text-base sm:text-xl md:text-2xl flex flex-row items-center justify-center font-overused-grotesk bg-white font-light overflow-hidden p-6 uppercase relative text-primary-red">
      <div className="absolute inset-0 w-full h-full blur-3xl">
        <img
          src={exampleImages[0].url}
          alt="city"
          className="w-full h-full object-cover overflow-hidden"
        />
      </div>
      <div className="absolute inset-0 flex justify-center items-center">
        <div className=" grid grid-cols-2 gap-y-12 gap-x-8 w-full text-red font-bold">
          <TextRotate
            texts={["New York", "Los Angeles", "Chicago", "Miami"]}
            mainClassName="justify-center"
            staggerFrom="first"
            initial={{ y: "100%" }}
            animate={{ y: 0 }}
            exit={{ y: "-120%" }}
            staggerDuration={0.04}
            splitLevelClassName="overflow-hidden"
            transition={{ type: "spring", damping: 30, stiffness: 400 }}
            rotationInterval={2500}
          />

          <TextRotate
            texts={["São Paulo", "Rio de Janeiro", "Salvador", "Brasília"]}
            mainClassName="justify-center"
            staggerFrom="center"
            initial={{ y: "100%" }}
            animate={{ y: 0 }}
            exit={{ y: "-120%" }}
            staggerDuration={0.04}
            splitLevelClassName="overflow-hidden"
            transition={{ type: "spring", damping: 30, stiffness: 400 }}
            rotationInterval={2500}
          />

          <TextRotate
            texts={["Tokyo", "Osaka", "Kyoto", "Sapporo"]}
            mainClassName="justify-center"
            staggerFrom="last"
            initial={{ y: "100%" }}
            animate={{ y: 0 }}
            exit={{ y: "-120%" }}
            staggerDuration={0.04}
            splitLevelClassName="overflow-hidden"
            transition={{ type: "spring", damping: 30, stiffness: 400 }}
            rotationInterval={2500}
          />

          <TextRotate
            texts={["Mumbai", "Delhi", "Bangalore", "Chennai"]}
            mainClassName="justify-center"
            staggerFrom="random"
            initial={{ y: "100%" }}
            animate={{ y: 0 }}
            exit={{ y: "-120%" }}
            staggerDuration={0.04}
            splitLevelClassName="overflow-hidden"
            transition={{ type: "spring", damping: 30, stiffness: 400 }}
            rotationInterval={2500}
          />
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/text-rotate-step-demo.tsx
파일 저장

"use client"

import { useRef } from "react"
import { MoveLeft, MoveRight } from "lucide-react"
import { LayoutGroup, motion } from "motion/react"

import TextRotate, { TextRotateRef } from "@/fancy/components/text/text-rotate"

export default function Preview() {
  const textRotateRef = useRef<TextRotateRef>(null)

  return (
    <div className="w-full h-full flex flex-col items-center justify-center font-overused-grotesk bg-white text-foreground dark:text-muted font-light overflow-hidden p-8 sm:p-20 md:p-24 gap-8">
      <LayoutGroup>
        <motion.p className="" layout>
          <TextRotate
            ref={textRotateRef}
            texts={[
              "this is the first text",
              "this is the 2nd",
              "we're at third!",
              "4th! keep going",
              "5th.",
              "this is the end.",
            ]}
            mainClassName="text-lg sm:text-2xl md:text-4xl justify-center flex"
            staggerFrom={"first"}
            animatePresenceMode="sync"
            loop={true}
            auto={false}
            staggerDuration={0.0}
            initial={{ opacity: 0, y: 0 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 0 }}
            transition={{ type: "spring", damping: 30, stiffness: 400 }}
            rotationInterval={3000}
            splitBy="words"
          />
        </motion.p>
      </LayoutGroup>

      <div className="flex gap-4">
        <button
          onClick={() => textRotateRef.current?.previous()}
          className="px-2 py-2 text-foreground dark:text-muted"
        >
          <MoveLeft className="w-3 h-3 sm:w-4 sm:h-4" />
        </button>
        <button
          onClick={() => textRotateRef.current?.next()}
          className="px-2 py-2 text-foreground dark:text-muted"
        >
          <MoveRight className="w-3 h-3 sm:w-4 sm:h-4" />
        </button>
      </div>
    </div>
  )
}
src/fancy/examples/text/typewriter-demo.tsx
파일 저장

import Typewriter from "@/fancy/components/text/typewriter"

export default function Preview() {
  return (
    <div className="w-full h-full md:text-3xl lg:text-4xl sm:text-2xl text-xl flex flex-row items-start justify-start bg-white text-foreground dark:text-muted font-normal overflow-hidden p-16 pt-48">
      <p className="whitespace-pre-wrap">
        <span>{"We're born 🌞 to "}</span>
        <Typewriter
          text={[
            "experience",
            "dance",
            "love",
            "be alive",
            "create things that make the world a better place",
          ]}
          speed={70}
          className="text-yellow-500 text-pretty"
          waitTime={1500}
          deleteSpeed={40}
          cursorChar={"_"}
        />
      </p>
    </div>
  )
}
src/fancy/examples/text/underline-demo.tsx
파일 저장

import Link from "next/link"

import CenterUnderline from "@/fancy/components/text/underline-center"
import ComesInGoesOutUnderline from "@/fancy/components/text/underline-comes-in-goes-out"
import GoesOutComesInUnderline from "@/fancy/components/text/underline-goes-out-comes-in"

export default function UnderlineDemo() {
  return (
    <div className="w-full h-full flex flex-col items-center justify-center bg-white">
      <div className="flex flex-row font-overused-grotesk items-start text-primary-blue h-full py-36 uppercase space-x-8 text-sm sm:text-lg md:text-xl lg:text-2xl">
        <div>Contact</div>
        <ul className="flex flex-col space-y-1 h-full">
          <Link className="" href="#">
            <CenterUnderline>LINKEDIN</CenterUnderline>
          </Link>
          <Link className="" href="#">
            <ComesInGoesOutUnderline direction="right">
              INSTAGRAM
            </ComesInGoesOutUnderline>
          </Link>
          <Link className="" href="#">
            <ComesInGoesOutUnderline direction="left">
              X (TWITTER)
            </ComesInGoesOutUnderline>
          </Link>

          <div className="pt-12">
            <ul className="flex flex-col space-y-1 h-full">
              <Link className="" href="#">
                <GoesOutComesInUnderline direction="left">
                  FANCY@FANCY.DEV
                </GoesOutComesInUnderline>
              </Link>
              <Link className="" href="#">
                <GoesOutComesInUnderline direction="right">
                  HELLO@FANCY.DEV
                </GoesOutComesInUnderline>
              </Link>
            </ul>
          </div>
        </ul>
      </div>
    </div>
  )
}
src/fancy/examples/text/underline-to-background-demo.tsx
파일 저장

"use client"

import { motion } from "motion/react"

import UnderlineToBackground from "@/fancy/components/text/underline-to-background"

export default function UnderlineToBackgroundDemo() {
  const fadeInVariants = {
    hidden: { opacity: 0 },
    visible: {
      opacity: 1,
      transition: { duration: 0.5, staggerChildren: 0.1 },
    },
  }

  const wordVariants = {
    hidden: { opacity: 0 },
    visible: { opacity: 1 },
  }

  const words = "Weekly goodies delivered straight to your inbox —".split(" ")

  return (
    <div className="w-full h-full flex flex-col items-center justify-center bg-[#f5f5f5]">
      <motion.h2
        className="text-primary-blue text-xl p-12 md:p-24"
        initial="hidden"
        animate="visible"
        variants={fadeInVariants}
      >
        {words.map((word, index) => (
          <motion.span
            key={index}
            variants={wordVariants}
            className="inline-block mr-1"
          >
            {word}
          </motion.span>
        ))}
        <motion.span variants={wordVariants} className="inline-block">
          <UnderlineToBackground
            targetTextColor="#f0f0f0"
            className="cursor-pointer"
          >
            subscribe
          </UnderlineToBackground>
        </motion.span>
      </motion.h2>
    </div>
  )
}
src/fancy/examples/text/variable-font-and-cursor-demo.tsx
파일 저장

"use client"

import { useRef } from "react"

import { useMousePosition } from "@/hooks/use-mouse-position"
import VariableFontAndCursor from "@/fancy/components/text/variable-font-and-cursor"

export default function Preview() {
  const containerRef = useRef<HTMLDivElement>(null)

  const { x, y } = useMousePosition(containerRef)

  return (
    <div
      className="w-full h-full rounded-lg items-center justify-center font-overused-grotesk p-24 bg-background relative cursor-none overflow-hidden"
      ref={containerRef}
    >
      {/* this is the important stuff */}
      <div className="w-full h-full items-center justify-center flex">
        <VariableFontAndCursor
          className="text-5xl sm:text-7xl md:text-9xl text-primary-orange"
          fontVariationMapping={{
            y: { name: "wght", min: 100, max: 900 },
            x: { name: "slnt", min: 0, max: -10 },
          }}
          containerRef={containerRef}
        >
          fancy!
        </VariableFontAndCursor>
      </div>

      {/* this is just fluff for the demo */}

      <div className="absolute bottom-8 left-8 flex flex-col font-azeret-mono">
        <span className="text-xs text-foreground/60 tabular-nums">
          x: {Math.round(x)}
        </span>
        <span className="text-xs text-foreground/60 tabular-nums">
          y: {Math.round(y)}
        </span>
      </div>

      <div
        className="absolute w-px h-screen bg-foreground/20 dark:bg-foreground top-0 -translate-x-1/2"
        style={{
          left: `${x}px`,
        }}
      />
      <div
        className="absolute w-screen h-px bg-foreground/20 dark:bg-foreground left-0 -translate-y-1/2"
        style={{
          top: `${y}px`,
        }}
      />
      <div
        className="absolute w-2 h-2 bg-primary-orange -translate-x-1/2 -translate-y-1/2 rounded-xs"
        style={{
          top: `${y}px`,
          left: `${x}px`,
        }}
      />
    </div>
  )
}
src/fancy/examples/text/variable-font-cursor-proximity-demo.tsx
파일 저장

"use client"

import { useRef } from "react"

import { cn } from "@/lib/utils"
import VariableFontCursorProximity from "@/fancy/components/text/variable-font-cursor-proximity"

const texts = ["Overstimulated", "Underutilized", "Familiar", "Extraordinary"]

export default function Preview() {
  const containerRef = useRef<HTMLDivElement>(null)

  return (
    <div
      className="w-full h-full rounded-lg items-center justify-center font-overused-grotesk bg-primary-red cursor-pointer relative overflow-hidden"
      ref={containerRef}
    >
      <div className="w-full h-full flex flex-col items-center justify-center gap-4 text-white">
        {texts.map((text, i) => (
          <VariableFontCursorProximity
            key={i}
            className={cn("text-4xl md:text-6xl lg:text-7xl leading-none")}
            fromFontVariationSettings="'wght' 400, 'slnt' 0"
            toFontVariationSettings="'wght' 900, 'slnt' -10"
            radius={200}
            containerRef={containerRef}
          >
            {text}
          </VariableFontCursorProximity>
        ))}
      </div>
    </div>
  )
}
src/fancy/examples/text/variable-font-cursor-proximity-falloff-demo.tsx
파일 저장

"use client"

import { useRef } from "react"

import VariableFontCursorProximity from "@/fancy/components/text/variable-font-cursor-proximity"

export default function Preview() {
  const containerRef = useRef<HTMLDivElement>(null)

  return (
    <div
      className="w-full h-full rounded-lg items-center justify-center font-overused-grotesk p-8 sm:p-16 md:p-20 lg:p-24 bg-white cursor-pointer relative overflow-hidden"
      ref={containerRef}
    >
      <div className="w-full h-full items-center justify-center grid text-justify">
        <VariableFontCursorProximity
          className="leading-tight text-xs sm:text-sm md:text-base lg:text-lg text-primary-red -m-4 p-2"
          fromFontVariationSettings="'wght' 400, 'slnt' 0"
          toFontVariationSettings="'wght' 900, 'slnt' -10"
          falloff="exponential"
          radius={70}
          containerRef={containerRef}
        >
          {`Modern typography is based primarily on the theories and principles of design evolved in the 20's and 30's of our century. It was Mallarmé and Rimbaud in the 19th century and Apollinaire in the early 20th century who paved the way to a new understanding of the possibilities inherent in typography and who, released from conventional prejudices and fetters, created through their experiments the basis for the pioneer achievements of the theoreticians and practitioners that followed. Walter Dexel, El Lissitzky, Kurt Schwitters, Jan Tschichold, Paul Renner, Moholy-Nagy, Joost Schmidt etc. breathed new life into an unduly rigid typography. In his book "Die neue Typografie" (1928) J. Tschichold formulated the rules of an up-to-date and objective typography which met the needs of the age.`}
        </VariableFontCursorProximity>
      </div>
    </div>
  )
}
src/fancy/examples/text/variable-font-hover-by-letter-demo.tsx
파일 저장

import VariableFontHoverByLetter from "@/fancy/components/text/variable-font-hover-by-letter"

export default function Preview() {
  return (
    <div className="w-full h-full rounded-lg sm:text-xl xs:text-sm md:text-2xl xl:text-3xl flex flex-col items-center justify-center font-overused-grotesk bg-white text-foreground dark:text-muted">
      <div className="w-full justify-start items-center p-6 sm:p-8 md:p-12 lg:p-16">
        <div className="w-3/4">
          <h2>OPEN ROLES ✽</h2>
          <ul className="flex flex-col space-y-1 mt-6 md:mt-12 h-full cursor-pointer">
            <VariableFontHoverByLetter
              label="DESIGN ENGINEER (US)"
              staggerDuration={0.03}
              fromFontVariationSettings="'wght' 400, 'slnt' 0"
              toFontVariationSettings="'wght' 900, 'slnt' -10"
            />
            <VariableFontHoverByLetter
              label="PRODUCT DESIGNER (US/UK)"
              staggerDuration={0.0}
              transition={{ duration: 1, type: "spring" }}
              fromFontVariationSettings="'wght' 400, 'slnt' -10"
              toFontVariationSettings="'wght' 900, 'slnt' -10"
            />
            <VariableFontHoverByLetter
              label="ENGINEERING MANAGER (US)"
              fromFontVariationSettings="'wght' 400, 'slnt' 0"
              toFontVariationSettings="'wght' 900, 'slnt' -10"
              staggerFrom={"last"}
            />
            <VariableFontHoverByLetter
              label="SALES ENGINEER (US)"
              staggerFrom={"center"}
              fromFontVariationSettings="'wght' 400, 'slnt' 0"
              toFontVariationSettings="'wght' 900, 'slnt' -10"
            />
          </ul>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/variable-font-hover-by-random-letter-demo.tsx
파일 저장

import VariableFontHoverByRandomLetter from "@/fancy/components/text/variable-font-hover-by-random-letter"

export default function Preview() {
  return (
    <div className="w-full h-full rounded-lg items-center justify-center font-overused-grotesk p-24 bg-linear-to-br text-teal bg-white ">
      <div className="w-full h-full items-center justify-center flex">
        <VariableFontHoverByRandomLetter
          label="Let's Go!"
          staggerDuration={0.03}
          className="rounded-full items-center flex justify-center cursor-pointer px-8 py-5 align-text-top text-4xl sm:text-5xl md:text-7xl"
          fromFontVariationSettings="'wght' 400, 'slnt' 0"
          toFontVariationSettings="'wght' 900, 'slnt' 0"
        />
      </div>
    </div>
  )
}
src/fancy/examples/text/vertical-cut-reveal-demo.tsx
파일 저장

import VerticalCutReveal from "@/fancy/components/text/vertical-cut-reveal"

export default function Preview() {
  return (
    <div className="w-full h-full xs:text-2xl bg-white text-2xl sm:text-4xl md:text-5xl lg:text-5xl xl:text-5xl flex flex-col items-start justify-center font-overused-grotesk p-10 md:p-16 lg:p-24 text-primary-blue tracking-wide uppercase">
      <VerticalCutReveal
        splitBy="characters"
        staggerDuration={0.025}
        staggerFrom="first"
        transition={{
          type: "spring",
          stiffness: 200,
          damping: 21,
        }}
      >
        {`HI 👋, FRIEND!`}
      </VerticalCutReveal>
      <VerticalCutReveal
        splitBy="characters"
        staggerDuration={0.025}
        staggerFrom="last"
        reverse={true}
        transition={{
          type: "spring",
          stiffness: 200,
          damping: 21,
          delay: 0.5,
        }}
      >
        {`🌤️ IT IS NICE ⇗ TO`}
      </VerticalCutReveal>
      <VerticalCutReveal
        splitBy="characters"
        staggerDuration={0.025}
        staggerFrom="center"
        transition={{
          type: "spring",
          stiffness: 200,
          damping: 21,
          delay: 1.1,
        }}
      >
        {`MEET 😊 YOU.`}
      </VerticalCutReveal>
    </div>
  )
}
src/fancy/examples/text/vertical-cut-reveal-letter-random-demo.tsx
파일 저장

import VerticalCutReveal from "@/fancy/components/text/vertical-cut-reveal"

export default function Preview() {
  return (
    <div className="w-full h-full text md:text-xl flex items-center justify-center font-overused-grotesk bg-white p-10 md:p-16 lg:p-24 text-primary-blue">
      <VerticalCutReveal
        splitBy="characters"
        staggerDuration={0.002}
        staggerFrom="random"
        transition={{
          type: "spring",
          stiffness: 200,
          damping: 35,
          delay: 0.1,
        }}
        containerClassName="text-[#00000] leading-snug"
      >
        {`“When a small, unassuming object exceeds our expectations, we are not only surprised but pleased. Our usual reaction is something like, "That little thing did all that?" Simplicity is about the unexpected pleasure derived from what is likely to be insignificant and would otherwise go unnoticed. The smaller the object, the more forgiving we can be when it misbehaves.”
        ― John Maeda,`}
      </VerticalCutReveal>
    </div>
  )
}
src/fancy/examples/text/vertical-cut-reveal-line-demo.tsx
파일 저장

import VerticalCutReveal from "@/fancy/components/text/vertical-cut-reveal"

export default function Preview() {
  return (
    <div className="w-full h-full text md:text-2xl lg:text-4xl flex flex-col items-start justify-center font-azeret-mono bg-white p-6 md:p-16 lg:p-20 xl:p-24 text-primary-blue tracking-wide ">
      <div className="flex flex-col justify-center w-full items-start space-y-4">
        <VerticalCutReveal
          splitBy="lines"
          staggerDuration={0.2}
          staggerFrom="first"
          transition={{
            type: "spring",
            stiffness: 250,
            damping: 30,
            delay: 0.2,
          }}
          containerClassName="text-[#00000] leading-relaxed"
        >
          {"→ We're on a mission\nto make the 🌐 web \nsuper fun again! ☺"}
        </VerticalCutReveal>
      </div>
    </div>
  )
}
src/fancy/examples/text/vertical-cut-reveal-scroll-demo.tsx
파일 저장

"use client"

import { useEffect, useRef } from "react"
import { useInView } from "motion/react"

import VerticalCutReveal, {
  VerticalCutRevealRef,
} from "@/fancy/components/text/vertical-cut-reveal"

export default function Preview() {
  const ref = useRef(null)
  const textRef = useRef<VerticalCutRevealRef>(null)
  const isInView = useInView(ref, { once: false })

  useEffect(() => {
    if (isInView) {
      textRef.current?.startAnimation()
    } else {
      textRef.current?.reset()
    }
  }, [isInView])

  return (
    <div className="w-full h-full font-overused-grotesk bg-primary-blue overflow-auto text-white text md:text-4xl lg:text-4xl font-bold text-xl">
      <div className="h-full flex w-full  justify-center items-center ">
        Scroll down champ ↓
      </div>
      <div className="h-full  flex text-white justify-center items-center">
        <div ref={ref}>
          <VerticalCutReveal
            splitBy="characters"
            staggerDuration={0.02}
            staggerFrom="first"
            transition={{
              type: "spring",
              stiffness: 200,
              damping: 35,
              delay: 0.1,
            }}
            containerClassName="text-[#00000] leading-snug"
            ref={textRef}
            autoStart={false}
          >
            {`howdy! 👋`}
          </VerticalCutReveal>
        </div>
      </div>
    </div>
  )
}
src/fancy/examples/text/vertical-cut-reveal-stagger-demo.tsx
파일 저장

import VerticalCutReveal from "@/fancy/components/text/vertical-cut-reveal"

export default function Preview() {
  return (
    <div className="w-full h-full text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl flex flex-col items-start justify-center font-overused-grotesk bg-white p-2 text-primary-blue tracking-wide uppercase font-bold">
      <div className="flex flex-col justify-center w-full items-center space-y-4">
        <VerticalCutReveal
          splitBy="characters"
          staggerDuration={0.05}
          staggerFrom="first"
          transition={{
            type: "spring",
            stiffness: 200,
            damping: 21,
            delay: 0,
          }}
        >
          {`THIS STAGGERS FROM FIRST`}
        </VerticalCutReveal>
        <VerticalCutReveal
          splitBy="characters"
          staggerDuration={0.05}
          staggerFrom="last"
          reverse={true}
          transition={{
            type: "spring",
            stiffness: 200,
            damping: 21,
            delay: 1,
          }}
        >
          {`THIS STAGGERS FROM LAST`}
        </VerticalCutReveal>
        <VerticalCutReveal
          splitBy="characters"
          staggerDuration={0.05}
          staggerFrom="center"
          transition={{
            type: "spring",
            stiffness: 200,
            damping: 21,
            delay: 2.3,
          }}
        >
          {`THIS STAGGERS FROM CENTER`}
        </VerticalCutReveal>
        <VerticalCutReveal
          splitBy="characters"
          staggerDuration={0.05}
          staggerFrom={5}
          transition={{
            type: "spring",
            stiffness: 200,
            damping: 21,
            delay: 3.2,
          }}
        >
          {`THIS ONE FROM THE 5TH CHARACTER`}
        </VerticalCutReveal>
      </div>
    </div>
  )
}
src/fancy/examples/text/vertical-cut-reveal-word-demo.tsx
파일 저장

import VerticalCutReveal from "@/fancy/components/text/vertical-cut-reveal"

export default function Preview() {
  return (
    <div className="w-full h-full text-lg md:text-2xl flex flex-col items-start justify-center font-calendas p-10 md:p-16 lg:p-24 bg-primary-blue text-white tracking-wide font-bold">
      <div className="flex flex-col justify-center w-full items-center space-y-4">
        <VerticalCutReveal
          splitBy="words"
          staggerDuration={0.1}
          staggerFrom="first"
          reverse={true}
          transition={{
            type: "spring",
            stiffness: 250,
            damping: 30,
            delay: 0,
          }}
        >
          {`super cool & awesome example text`}
        </VerticalCutReveal>
      </div>
    </div>
  )
}
src/fancy/schema.ts
파일 저장

import * as z from "zod"

// Schema for deeply nested tailwind config
const tailwindSchema = z.object({
  config: z.record(z.string(), z.unknown()).optional()
}).optional()

export const registrySchema = z.record(
  z.string(),
  z.object({
    name: z.string(),
    dependencies: z.array(z.string()).optional(), // external dependencies. inferred from the import statements, and fetched from the addition .json file next to the component .tsx file
    devDependencies: z.array(z.string()).optional(), // dev dependencies. fetched from the addition .json file next to the component .tsx file
    registryDependencies: z.array(z.string()).optional(), // other component dependencies
    files: z.array(z.object({
      path: z.string(),
      type: z.enum(["registry:ui", "registry:block", "registry:hook" , "registry:lib"]),
    })),
    type: z.enum(["registry:ui", "registry:block", "registry:hook" , "registry:lib"]),
    component: z.function().args(z.any()).returns(z.any()).optional(), // lazy loading component for the documentation page. Not part of the output .json file
    tailwind: tailwindSchema,
    cssVars: z.record(z.string(), z.unknown()).optional(),
    author: z.string().optional()
  })
)

export type Registry = z.infer<typeof registrySchema>
src/hooks/use-debounced-dimensions.ts
파일 저장

import { RefObject, useEffect, useState } from "react"

interface Dimensions {
  width: number
  height: number
}

export function useDimensions(
  ref: RefObject<HTMLElement | SVGElement | null>
): Dimensions {
  const [dimensions, setDimensions] = useState<Dimensions>({
    width: 0,
    height: 0,
  })

  useEffect(() => {
    let timeoutId: NodeJS.Timeout

    const updateDimensions = () => {
      if (ref.current) {
        const { width, height } = ref.current.getBoundingClientRect()
        setDimensions({ width, height })
      }
    }

    const debouncedUpdateDimensions = () => {
      clearTimeout(timeoutId)
      timeoutId = setTimeout(updateDimensions, 250) // Wait 250ms after resize ends
    }

    // Initial measurement
    updateDimensions()

    window.addEventListener("resize", debouncedUpdateDimensions)

    return () => {
      window.removeEventListener("resize", debouncedUpdateDimensions)
      clearTimeout(timeoutId)
    }
  }, [ref])

  return dimensions
}
src/hooks/use-detect-browser.ts
파일 저장

import React from "react"

export default function useDetectBrowser() {
  if (typeof window === "undefined") return null

  let sBrowser,
    sUsrAg = navigator.userAgent
  const [browserName, setBrowserName] = React.useState("")

  React.useEffect(() => {
    if (sUsrAg.indexOf("Firefox") > -1) {
      sBrowser = "Firefox"
      // "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0"
    } else if (sUsrAg.indexOf("SamsungBrowser") > -1) {
      sBrowser = "Samsung Internet"
      // "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G955F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.4 Chrome/67.0.3396.87 Mobile Safari/537.36
    } else if (sUsrAg.indexOf("Opera") > -1 || sUsrAg.indexOf("OPR") > -1) {
      sBrowser = "Opera"
      // "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 OPR/57.0.3098.106"
    } else if (sUsrAg.indexOf("Trident") > -1) {
      sBrowser = "IE"
      // "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; Zoom 3.6.0; wbx 1.0.0; rv:11.0) like Gecko"
    } else if (sUsrAg.indexOf("Edge") > -1) {
      sBrowser = "Edge"
      // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299"
    } else if (sUsrAg.indexOf("Chrome") > -1) {
      sBrowser = "Chrome"
      // "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/66.0.3359.181 Chrome/66.0.3359.181 Safari/537.36"
    } else if (sUsrAg.indexOf("Safari") > -1) {
      sBrowser = "Safari"
      // "Mozilla/5.0 (iPhone; CPU iPhone OS 11_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1 980x1306"
    } else {
      sBrowser = "unknown"
    }
    setBrowserName(sBrowser)
  }, [])
  return browserName
}
src/hooks/use-dimensions.ts
파일 저장

import { RefObject, useEffect, useState } from "react"

interface Dimensions {
  width: number
  height: number
}

export function useDimensions(
  ref: RefObject<HTMLElement | SVGElement | null>
): Dimensions {
  const [dimensions, setDimensions] = useState<Dimensions>({
    width: 0,
    height: 0,
  })

  useEffect(() => {
    const updateDimensions = () => {
      if (ref.current) {
        const { width, height } = ref.current.getBoundingClientRect()
        setDimensions({ width, height })
      }
    }

    updateDimensions()
    window.addEventListener("resize", updateDimensions)

    return () => window.removeEventListener("resize", updateDimensions)
  }, [ref])

  return dimensions
}
src/hooks/use-elastic-line-events.ts
파일 저장

import { useEffect, useState } from "react"

import { useDimensions } from "@/hooks/use-dimensions"
import { useMousePosition } from "@/hooks/use-mouse-position"

interface ElasticLineEvents {
  isGrabbed: boolean
  controlPoint: { x: number; y: number }
}

export function useElasticLineEvents(
  containerRef: React.RefObject<SVGSVGElement | null>,
  isVertical: boolean,
  grabThreshold: number,
  releaseThreshold: number
): ElasticLineEvents {
  const mousePosition = useMousePosition(containerRef)
  const dimensions = useDimensions(containerRef)
  const [isGrabbed, setIsGrabbed] = useState(false)
  const [controlPoint, setControlPoint] = useState({
    x: dimensions.width / 2,
    y: dimensions.height / 2,
  })

  useEffect(() => {
    if (containerRef.current) {
      const { width, height } = dimensions
      const x = mousePosition.x
      const y = mousePosition.y

      // Check if mouse is outside container bounds
      const isOutsideBounds = x < 0 || x > width || y < 0 || y > height

      if (isOutsideBounds) {
        setIsGrabbed(false)
        return
      }

      let distance: number
      let newControlPoint: { x: number; y: number }

      if (isVertical) {
        const midX = width / 2
        distance = Math.abs(x - midX)
        newControlPoint = {
          x: midX + 2.2 * (x - midX),
          y: y,
        }
      } else {
        const midY = height / 2
        distance = Math.abs(y - midY)
        newControlPoint = {
          x: x,
          y: midY + 2.2 * (y - midY),
        }
      }

      setControlPoint(newControlPoint)

      if (!isGrabbed && distance < grabThreshold) {
        setIsGrabbed(true)
      } else if (isGrabbed && distance > releaseThreshold) {
        setIsGrabbed(false)
      }
    }
  }, [mousePosition, isVertical, isGrabbed, grabThreshold, releaseThreshold])

  return { isGrabbed, controlPoint }
}
src/hooks/use-line-breakdown.ts
파일 저장

import { RefObject, useEffect, useState } from "react"

interface UseLineBreakdownResult {
  lineCount: number
  lines: string[][]
}

export function useLineBreakdown(
  elementRef: RefObject<HTMLElement | null>,
  text: string
): UseLineBreakdownResult {
  const [breakdown, setBreakdown] = useState<UseLineBreakdownResult>({
    lineCount: 0,
    lines: [[]],
  })

  useEffect(() => {
    const element = elementRef.current
    if (!element) return

    const calculateLines = () => {
      // Get basic measurements
      const style = window.getComputedStyle(element)
      const lineHeight = parseInt(style.lineHeight)
      const elementHeight = element.offsetHeight

      const linesCount = Math.ceil(elementHeight / lineHeight)

      console.log(elementHeight / lineHeight)

      // Create temporary elements to measure text
      const tempSpan = element.appendChild(document.createElement("span"))
      tempSpan.style.visibility = "hidden"
      tempSpan.style.position = "absolute"
      tempSpan.style.whiteSpace = "nowrap"
      element.appendChild(tempSpan)

      // Split text into words
      const words = text.split(" ")
      const lineGroups: string[][] = [[]]
      let currentLine = 0
      let previousTop = 0

      // Group words into lines
      words.forEach((word, index) => {
        tempSpan.textContent = word
        const rect = tempSpan.getBoundingClientRect()

        if (rect.top > previousTop && index > 0) {
          currentLine++
          lineGroups[currentLine] = []
          previousTop = rect.top
        }

        lineGroups[currentLine].push(word)
      })

      element.removeChild(tempSpan)

      setBreakdown({
        lineCount: lineGroups.length,
        lines: lineGroups,
      })
    }

    calculateLines()

    const resizeObserver = new ResizeObserver(calculateLines)
    resizeObserver.observe(element)

    return () => {
      resizeObserver.disconnect()
    }
  }, [elementRef, text])

  return breakdown
}
src/hooks/use-line-count.ts
파일 저장

import { useEffect, useState } from "react"

export function useLineCount(element: HTMLElement) {
  const [lineCount, setLineCount] = useState(0)

  useEffect(() => {
    const calculateLines = () => {
      if (!element) return
      const elementHeight = element.offsetHeight
      const style = window.getComputedStyle(element)
      const lineHeight = parseInt(style.lineHeight)
      const lines = Math.floor(elementHeight / lineHeight)
      setLineCount(lines)
    }

    calculateLines()

    const resizeObserver = new ResizeObserver(calculateLines)
    resizeObserver.observe(element)

    return () => {
      resizeObserver.disconnect()
    }
  }, [element])

  return lineCount
}
src/hooks/use-mounted.ts
파일 저장

import * as React from "react"

export function useMounted() {
  const [mounted, setMounted] = React.useState(false)

  React.useEffect(() => {
    setMounted(true)
  }, [])

  return mounted
}
src/hooks/use-mouse-position-ref.ts
파일 저장

import { RefObject, useEffect, useRef } from "react"

export const useMousePositionRef = (
  containerRef?: RefObject<HTMLElement | SVGElement | null>
) => {
  const positionRef = useRef({ x: 0, y: 0 })

  useEffect(() => {
    const updatePosition = (x: number, y: number) => {
      if (containerRef && containerRef.current) {
        const rect = containerRef.current.getBoundingClientRect()
        const relativeX = x - rect.left
        const relativeY = y - rect.top

        // Calculate relative position even when outside the container
        positionRef.current = { x: relativeX, y: relativeY }
      } else {
        positionRef.current = { x, y }
      }
    }

    const handleMouseMove = (ev: MouseEvent) => {
      updatePosition(ev.clientX, ev.clientY)
    }

    const handleTouchMove = (ev: TouchEvent) => {
      const touch = ev.touches[0]
      updatePosition(touch.clientX, touch.clientY)
    }

    // Listen for both mouse and touch events
    window.addEventListener("mousemove", handleMouseMove)
    window.addEventListener("touchmove", handleTouchMove)

    return () => {
      window.removeEventListener("mousemove", handleMouseMove)
      window.removeEventListener("touchmove", handleTouchMove)
    }
  }, [containerRef])

  return positionRef
}
src/hooks/use-mouse-position.ts
파일 저장

import { RefObject, useEffect, useState } from "react"

export const useMousePosition = (
  containerRef?: RefObject<HTMLElement | SVGElement | null>
) => {
  const [position, setPosition] = useState({ x: 0, y: 0 })

  useEffect(() => {
    const updatePosition = (x: number, y: number) => {
      if (containerRef && containerRef.current) {
        const rect = containerRef.current.getBoundingClientRect()
        const relativeX = x - rect.left
        const relativeY = y - rect.top

        // Calculate relative position even when outside the container
        setPosition({ x: relativeX, y: relativeY })
      } else {
        setPosition({ x, y })
      }
    }

    const handleMouseMove = (ev: MouseEvent) => {
      updatePosition(ev.clientX, ev.clientY)
    }

    const handleTouchMove = (ev: TouchEvent) => {
      const touch = ev.touches[0]
      updatePosition(touch.clientX, touch.clientY)
    }

    // Listen for both mouse and touch events
    window.addEventListener("mousemove", handleMouseMove)
    window.addEventListener("touchmove", handleTouchMove)

    return () => {
      window.removeEventListener("mousemove", handleMouseMove)
      window.removeEventListener("touchmove", handleTouchMove)
    }
  }, [containerRef])

  return position
}
src/hooks/use-mouse-vector.ts
파일 저장

import { RefObject, useEffect, useState } from "react"

export const useMouseVector = (
  containerRef?: RefObject<HTMLElement | SVGElement | null>
) => {
  const [position, setPosition] = useState({ x: 0, y: 0 })
  const [vector, setVector] = useState({ dx: 0, dy: 0 })

  useEffect(() => {
    let lastPosition = { x: 0, y: 0 }

    const updatePosition = (x: number, y: number) => {
      let newX, newY

      if (containerRef && containerRef.current) {
        const rect = containerRef.current.getBoundingClientRect()
        newX = x - rect.left
        newY = y - rect.top
      } else {
        newX = x
        newY = y
      }

      // Calculate the movement vector
      const dx = newX - lastPosition.x
      const dy = newY - lastPosition.y

      setVector({ dx, dy })
      setPosition({ x: newX, y: newY })
      lastPosition = { x: newX, y: newY }
    }

    const handleMouseMove = (ev: MouseEvent) => {
      updatePosition(ev.clientX, ev.clientY)
    }

    const handleTouchMove = (ev: TouchEvent) => {
      const touch = ev.touches[0]
      updatePosition(touch.clientX, touch.clientY)
    }

    // Listen for both mouse and touch events
    window.addEventListener("mousemove", handleMouseMove)
    window.addEventListener("touchmove", handleTouchMove)

    return () => {
      window.removeEventListener("mousemove", handleMouseMove)
      window.removeEventListener("touchmove", handleTouchMove)
    }
  }, [containerRef])

  return { position, vector }
}
src/hooks/use-screen-size.ts
파일 저장

import { useEffect, useState } from "react"

// Define the possible screen sizes as a const array for better type inference
const SCREEN_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl"] as const

// Create a union type from the array
export type ScreenSize = (typeof SCREEN_SIZES)[number]

// Type-safe size order mapping
const sizeOrder: Record<ScreenSize, number> = {
  xs: 0,
  sm: 1,
  md: 2,
  lg: 3,
  xl: 4,
  "2xl": 5,
} as const

class ComparableScreenSize {
  constructor(private value: ScreenSize) {}

  toString(): ScreenSize {
    return this.value
  }

  valueOf(): number {
    return sizeOrder[this.value]
  }

  // Add type predicate methods for better TypeScript support
  equals(other: ScreenSize): boolean {
    return this.value === other
  }

  lessThan(other: ScreenSize): boolean {
    return this.valueOf() < sizeOrder[other]
  }

  greaterThan(other: ScreenSize): boolean {
    return this.valueOf() > sizeOrder[other]
  }

  lessThanOrEqual(other: ScreenSize): boolean {
    return this.valueOf() <= sizeOrder[other]
  }

  greaterThanOrEqual(other: ScreenSize): boolean {
    return this.valueOf() >= sizeOrder[other]
  }
}

const useScreenSize = (): ComparableScreenSize => {
  const [screenSize, setScreenSize] = useState<ScreenSize>("xs")

  useEffect(() => {
    const handleResize = () => {
      const width = window.innerWidth

      if (width >= 1536) {
        setScreenSize("2xl")
      } else if (width >= 1280) {
        setScreenSize("xl")
      } else if (width >= 1024) {
        setScreenSize("lg")
      } else if (width >= 768) {
        setScreenSize("md")
      } else if (width >= 640) {
        setScreenSize("sm")
      } else {
        setScreenSize("xs")
      }
    }

    handleResize()
    window.addEventListener("resize", handleResize)
    return () => window.removeEventListener("resize", handleResize)
  }, [])

  return new ComparableScreenSize(screenSize)
}

export default useScreenSize
src/lib/events.ts
파일 저장

import va from "@vercel/analytics"
import { z } from "zod"

const eventSchema = z.object({
  name: z.enum([
    "copy_npm_command",
    "copy_usage_import_code",
    "copy_usage_code",
    "copy_primitive_code",
  ]),
  // declare type AllowedPropertyValues = string | number | boolean | null
  properties: z
    .record(z.union([z.string(), z.number(), z.boolean(), z.null()]))
    .optional(),
})

export type Event = z.infer<typeof eventSchema>

export function trackEvent(input: Event): void {
  const event = eventSchema.parse(input)
  if (event) {
    va.track(event.name, event.properties)
  }
}
src/lib/get-components.ts
파일 저장

import fs from "node:fs"
import path from "node:path"

export const COMPONENTS_DIRECTORY = "/src/content/docs/components/"

export interface Component {
  name: string
  category: string
  thumbnail: {
    url: string
  }
  demo: {
    url: string
  }
}

export function getAllComponentNames(): string[] {
  const componentsPath = path.join(process.cwd(), COMPONENTS_DIRECTORY)
  const categories = fs.readdirSync(componentsPath, { withFileTypes: true })
    .filter(dirent => dirent.isDirectory())
    .map(dirent => dirent.name)

  const componentNames: string[] = []

  for (const category of categories) {
    const categoryPath = path.join(componentsPath, category)
    const files = fs.readdirSync(categoryPath)
      .filter(file => file.endsWith('.mdx'))
      .map(file => file.replace('.mdx', ''))

    componentNames.push(...files)
  }

  return componentNames
}

export function getAllComponents(): Component[] {
  const componentsPath = path.join(process.cwd(), COMPONENTS_DIRECTORY)
  const categories = fs.readdirSync(componentsPath, { withFileTypes: true })
    .filter(dirent => dirent.isDirectory())
    .map(dirent => dirent.name)

  const components: Component[] = []

  for (const category of categories) {
    const categoryPath = path.join(componentsPath, category)
    const files = fs.readdirSync(categoryPath)
      .filter(file => file.endsWith('.mdx'))
      .map(file => file.replace('.mdx', ''))

    for (const componentName of files) {
      components.push({
        name: componentName,
        category: category,
        thumbnail: {
          url: `${process.env.BUNNY_CDN_URL}/thumbnails/${componentName}.jpg`
        },
        demo: {
          url: `${process.env.BUNNY_CDN_URL}/demos/${componentName}.mp4`
        }
      })
    }
  }

  return components
}

export function getComponentByName(name: string): Component | undefined {
  const componentsPath = path.join(process.cwd(), COMPONENTS_DIRECTORY)
  const categories = fs.readdirSync(componentsPath, { withFileTypes: true })
    .filter(dirent => dirent.isDirectory())
    .map(dirent => dirent.name)

  for (const category of categories) {
    const categoryPath = path.join(componentsPath, category)
    const files = fs.readdirSync(categoryPath)
      .filter(file => file.endsWith('.mdx'))
      .map(file => file.replace('.mdx', ''))

    if (files.includes(name)) {
      return {
        name: name,
        category: category,
        thumbnail: {
          url: `${process.env.BUNNY_CDN_URL}/thumbnails/${name}.jpg`
        },
        demo: {
          url: `${process.env.BUNNY_CDN_URL}/demos/${name}.mp4`
        }
      }
    }
  }

  return undefined
}

src/lib/get-docs.ts
파일 저장

import fs from "node:fs"
import path from "node:path"
import { mdxComponents } from "@/mdx-components"
import { compileMDX } from "next-mdx-remote/rsc"

import { Doc } from "@/types/types"

import { getTableOfContents } from "./toc"

export const CONTENT_DIRECTORY = "/src/content/docs/"

export async function getDocFromParams({ params }: { params: { slug: string[] } }): Promise<Doc> {
  const source = fs.readFileSync(
    path.join(process.cwd(), CONTENT_DIRECTORY, params.slug.join("/")) + ".mdx",
    "utf8"
  )

  const toc = await getTableOfContents(source)

  // Use the Next.js component mappings
  const components = mdxComponents()

  const { content, frontmatter } = await compileMDX({
    source,
    options: { parseFrontmatter: true },
    components,
  })

  return {
    slug: params.slug.join("/"),
    slugAsParams: params.slug.join("/"),
    _id: params.slug.join("/"),
    type: "Doc",
    title: String(frontmatter.title),
    description: String(frontmatter.description),
    published: Boolean(frontmatter.published),
    featured: Boolean(frontmatter.featured),
    component: Boolean(frontmatter.component),
    author: String(frontmatter.author),
    toc: toc,
    body: content,
  }
}
src/lib/toc.ts
파일 저장

// @ts-nocheck
// TODO: I'll fix this later.

import { toc } from "mdast-util-toc"
import { remark } from "remark"
import { visit } from "unist-util-visit"

const textTypes = ["text", "emphasis", "strong", "inlineCode"]

function flattenNode(node) {
  const p = []
  visit(node, (node) => {
    if (!textTypes.includes(node.type)) return
    p.push(node.value)
  })
  return p.join(``)
}

interface Item {
  title: string
  url: string
  items?: Item[]
}

interface Items {
  items?: Item[]
}

function getItems(node, current): Items {
  if (!node) {
    return {}
  }

  if (node.type === "paragraph") {
    visit(node, (item) => {
      if (item.type === "link") {
        current.url = item.url
        current.title = flattenNode(node)
      }

      if (item.type === "text") {
        current.title = flattenNode(node)
      }
    })

    return current
  }

  if (node.type === "list") {
    current.items = node.children.map((i) => getItems(i, {}))

    return current
  } else if (node.type === "listItem") {
    const heading = getItems(node.children[0], {})

    if (node.children.length > 1) {
      getItems(node.children[1], heading)
    }

    return heading
  }

  return {}
}

const getToc = () => (node, file) => {
  const table = toc(node)
  if (!table.map) return // Add this check
  const items = getItems(table.map, {})

  // Change this to data.toc to match remark's expectations
  file.data = { toc: items }
}

function preprocessContentForToc(content: string): string {
  // Remove frontmatter section
  let processed = content.replace(/^---[\s\S]*?---\n/, "")
  
  // Remove JSX components that break markdown parsing
  // Remove CodeSnippet components and their content
  processed = processed.replace(/<CodeSnippet[^>]*>[\s\S]*?<\/CodeSnippet>/g, "")
  
  // Remove Table components and their content  
  processed = processed.replace(/<Table>[\s\S]*?<\/Table>/g, "")
  
  // Remove other JSX components but keep their content if they're inline
  processed = processed.replace(/<ComponentPreview[^>]*\/>/g, "")
  processed = processed.replace(/<ComponentSource[^>]*\/>/g, "")
  processed = processed.replace(/<InstallTabs[^>]*\/>/g, "")
  processed = processed.replace(/<Tabs[^>]*>[\s\S]*?<\/Tabs>/g, "")
  
  // Remove self-closing JSX tags
  processed = processed.replace(/<[A-Z][^>]*\/>/g, "")
  
  // Remove opening/closing JSX tags but keep content
  processed = processed.replace(/<\/?[A-Z][^>]*>/g, "")
  
  return processed
}

export type TableOfContents = Items

export async function getTableOfContents(
  content: any
): Promise<TableOfContents> {
  const markdownContent =
    typeof content === "string" ? content : content?.props?.children || ""

  // Preprocess content to remove JSX components that break markdown parsing
  const cleanedContent = preprocessContentForToc(markdownContent)

  try {
    const processedContent = await remark()
      .use(() => (node, file) => {
        const table = toc(node)
        if (!table.map) return
        const items = getItems(table.map, {})
        file.data = { toc: items }
      })
      .process(cleanedContent)

    return processedContent.data.toc || {}
  } catch (error) {
    console.error("Error generating table of contents:", error)
    return {}
  }
}
src/lib/utils.ts
파일 저장

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function absoluteUrl(path: string) {
  return `${process.env.NEXT_PUBLIC_APP_URL}${path}`
}
src/mdx-components.tsx
파일 저장

import { Children } from "react"
import { ExternalLinkIcon } from "lucide-react"
import type { MDXComponents } from "mdx/types"

import { cn } from "@/lib/utils"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CodeSnippet } from "@/components/code-snippet"
import { ComponentPreview } from "@/components/component-preview"
import { ComponentSource } from "@/components/component-source"
import { ExplanationDemo } from "@/components/explanation-demo"
import { InstallTabs } from "@/components/install-tabs"

import "katex/dist/katex.min.css"

import Link from "next/link"
import { BlockMath, InlineMath } from "react-katex"

export function mdxComponents(components?: MDXComponents): MDXComponents {
  return {
    h1: ({ className, children, ...props }: React.ComponentProps<"h1">) => (
      <h1
        id={children?.toString().toLowerCase().replace(/\s+/g, "-")}
        className={cn(
          "text-[44px] font-calendas tracking-tighter text-pretty leading-tight",
          className
        )}
        {...props}
      >
        {children}
      </h1>
    ),
    h2: ({ className, children, ...props }: React.ComponentProps<"h2">) => (
      <>
        <h2
          id={children?.toString().toLowerCase().replace(/\s+/g, "-")}
          className={cn("text-3xl md:text-4xl font-medium mb-0 py-0 mt-14 tracking-tight", className)}
          {...props}
        >
          {children}
        </h2>
        <hr className="mt-2.5" />
      </>
    ),
    h3: ({ className, children, ...props }: React.ComponentProps<"h3">) => (
      <h3
        id={children?.toString().toLowerCase().replace(/\s+/g, "-")}
        className={cn("text-xl md:text-2xl font-medium py-0 mt-12 [h2+hr+&]:mt-0 tracking-tight", className)}
        {...props}
      >
        {children}
      </h3>
    ),
    h4: ({ className, children, ...props }: React.ComponentProps<"h4">) => (
      <h4
        id={children?.toString().toLowerCase().replace(/\s+/g, "-")}
        className={cn("text-lg md:text-xl font-medium py-0 mt-10 [h3+&]:mt-0 tracking-tight", className)}
        {...props}
      >
        {children}
      </h4>
    ),
    a: ({
      className,
      children,
      ...props
    }: React.HTMLAttributes<HTMLAnchorElement>) => (
      <a
        className={cn(
          "font-medium text-base md:text-lg text-blue hover:text-blue-400 dark:text-blue-400 dark:hover:text-blue-300 duration-300 ease-out transition-[color,background-color,opacity] inline-flex items-center leading-0 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue",
          className
        )}
        {...props}
      >
        {children}
        <ExternalLinkIcon className="ml-1 mt-0.5" size={14} strokeWidth={2.5} />
      </a>
    ),
    Link: ({
      className,
      href,
      children,
      ...props
    }: React.ComponentProps<typeof Link>) => (
      <Link
        href={href}
        className={cn(
          "font-medium text-base md:text-lg text-blue hover:text-blue-400 dark:text-blue-400 dark:hover:text-blue-300 duration-300 ease-out transition-[color,background-color,opacity] items-center leading-0 rounded-sm focus-primary",
          className
        )}
        {...props}
      >
        {children}
      </Link>
    ),
    p: ({
      className,
      ...props
    }: React.HTMLAttributes<HTMLParagraphElement>) => (
      <p className={cn("text-base md:text-lg text-pretty", className)} {...props} />
    ),
    strong: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
      <strong className={cn("font-semibold", className)} {...props} />
    ),
    em: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
      <em
        className={cn(className)}
        style={{ fontVariationSettings: "'slnt' -10" }}
        {...props}
      />
    ),
    ul: ({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) => (
      <ul
        className={cn("list-disc ml-3 list-outside space-y-3", className)}
        {...props}
      />
    ),
    ol: ({ className, ...props }: React.HTMLAttributes<HTMLOListElement>) => (
      <ol
        className={cn("list-outside list-decimal ml-6 space-y-3", className)}
        {...props}
      />
    ),
    li: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
      <li
        className={cn(
          "marker:text-sm [&>ul]:marker:text-[10px] [&>ol]:marker:text-base text-base md:text-lg first:mt-2 last:pb-4",
          className
        )}
        {...props}
      />
    ),
    math: ({ children }) => <BlockMath>{children}</BlockMath>,
    inlineMath: ({ children }) => <InlineMath>{children}</InlineMath>,
    blockquote: ({
      className,
      ...props
    }: React.HTMLAttributes<HTMLElement>) => (
      <blockquote
        className={cn("mt-2 border-l-1 pl-0 px-6", className)}
        style={{ fontVariationSettings: "'slnt' -10" }}
        {...props}
      />
    ),
    img: ({
      className,
      alt,
      ...props
    }: React.ImgHTMLAttributes<HTMLImageElement>) => (
      //@ts-expect-error img src expects a Blob or string
      (<ImageComponent
        src={props.src as string}
        alt={alt as string}
        caption={true}
        className={className}
        {...props}
      />)
    ),
    hr: ({ ...props }: React.HTMLAttributes<HTMLHRElement>) => (
      <hr className="" {...props} />
    ),
    code: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
      <code
        className={cn(
          "font-fira-mono text-xs md:text-sm px-0.5 py-px md:px-1 md:py-0.5 border border-border rounded-md leading-6 bg-muted sm:whitespace-pre box-decoration-clone",
          className
        )}
        {...props}
      />
    ),
    InstallTabs,
    CodeSnippet: ({
      className,
      title,
      children,
      ...props
    }: React.HTMLAttributes<HTMLElement> & {
      title?: string
    }) => {
      // Extract code content and language from children
      const preElement = Children.toArray(children)[0] as React.ReactElement<any>

      //@ts-ignore
      const codeElement = preElement?.props?.children as React.ReactElement<{
        className?: string
        children?: string
      }>

      if (!codeElement) return null

      const code = codeElement.props.children || ""
      const language =
        codeElement.props.className?.replace("language-", "") || "typescript"

      return (
        <CodeSnippet title={title} code={code} language={language} {...props} />
      )
    },
    ComponentPreview,
    ComponentSource,
    ExplanationDemo,
    Table: ({ className, ...props }: React.ComponentProps<typeof Table>) => (
      <div className="rounded-2xl overflow-hidden border border-border w-full">
        <Table className={cn("h-full w-full text-xs", className)} {...props} />
      </div>
    ),
    TableHeader: ({
      className,
      ...props
    }: React.ComponentProps<typeof TableHeader>) => (
      <TableHeader
        className={cn(
          "bg-muted dark:bg-background text-sm font-normal text-foreground",
          className
        )}
        {...props}
      />
    ),
    TableBody: ({
      className,
      ...props
    }: React.ComponentProps<typeof TableBody>) => (
      <TableBody
        className={cn("font-mono text-xs", className)}
        {...props}
      />
    ),
    TableRow: ({
      className,
      ...props
    }: React.ComponentProps<typeof TableRow>) => (
      <TableRow className={cn("hover:bg-transparent", className)} {...props} />
    ),
    TableCell: ({
      className,
      ...props
    }: React.ComponentProps<typeof TableCell>) => (
      <TableCell
        className={cn("font-mono text-xs [&_*]:!text-xs [&_code]:!text-xs [&_code]:whitespace-pre-wrap [&_code]:box-decoration-clone", className)}
        {...props}
      />
    ),
    TableHead: ({
      className,
      ...props
    }: React.ComponentProps<typeof TableHead>) => (
      <TableHead className={cn(className)} {...props} />
    ),
    Tabs: ({ className, ...props }: React.ComponentProps<typeof Tabs>) => (
      <Tabs className={cn("relative w-full", className)} {...props} />
    ),
    TabsList: ({
      className,
      ...props
    }: React.ComponentProps<typeof TabsList>) => (
      <TabsList
        className={cn(
          "w-full justify-start rounded-none bg-transparent p-0 space-x-3 px-3",
          className
        )}
        {...props}
      />
    ),
    TabsTrigger: ({
      className,
      ...props
    }: React.ComponentProps<typeof TabsTrigger>) => (
      <TabsTrigger
        className={cn(
          "relative text-base h-7 mt-3 bg-transparent px-0 font-semibold text-muted-foreground shadow-none data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none hover:font-semibold hover:text-foreground duration-300 ease-out transition-[colors,background-color,text-color] focus-visible:outline-hidden focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-offset-2 rounded-lg focus-visible:outline-primary-blue",
          className
        )}
        {...props}
      />
    ),
    TabsContent: ({
      className,
      ...props
    }: React.ComponentProps<typeof TabsContent>) => (
      <TabsContent className={cn("space-y-5",className)} {...props} />
    ),
    ...components,
  };
}
src/scripts/build-docs-markdown.ts
파일 저장

// @ts-ignore
const fs = require("fs")
// @ts-ignore
const path = require("path")

// @ts-ignore
const baseDir = path.join(__dirname, "..", "..")
const CONTENT_DIRECTORY = "/src/content/docs/"
const outputDir = path.join(baseDir, "public", "docs")

interface ConversionOptions {
  includeSourceCode?: boolean
  includeInstallInstructions?: boolean
}

interface TocItem {
  text: string
  anchor: string
  level: number
  children: TocItem[]
}

function generateTableOfContents(content: string): string {
  // Extract headings from the markdown content
  const headingRegex = /^(#{2,6})\s+(.+)$/gm
  const headings: { level: number; text: string; anchor: string }[] = []
  let match

  while ((match = headingRegex.exec(content)) !== null) {
    const level = match[1].length
    const text = match[2].trim()
    const anchor = generateAnchor(text)
    
    headings.push({ level, text, anchor })
  }

  if (headings.length === 0) {
    return ""
  }

  // Build nested TOC structure
  const tocItems = buildTocStructure(headings)
  
  // Generate TOC markdown
  let toc = "## Table of Contents\n\n"
  toc += generateTocMarkdown(tocItems)
  toc += "\n"
  
  return toc
}

function generateAnchor(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
    .replace(/\s+/g, '-') // Replace spaces with hyphens
    .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
    .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
}

function buildTocStructure(headings: { level: number; text: string; anchor: string }[]): TocItem[] {
  const root: TocItem[] = []
  const stack: TocItem[] = []

  for (const heading of headings) {
    const item: TocItem = {
      text: heading.text,
      anchor: heading.anchor,
      level: heading.level,
      children: []
    }

    // Find the appropriate parent
    while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
      stack.pop()
    }

    if (stack.length === 0) {
      root.push(item)
    } else {
      stack[stack.length - 1].children.push(item)
    }

    stack.push(item)
  }

  return root
}

function generateTocMarkdown(items: TocItem[], depth: number = 0): string {
  let markdown = ""
  const indent = "  ".repeat(depth)

  for (const item of items) {
    markdown += `${indent}- [${item.text}](#${item.anchor})\n`
    if (item.children.length > 0) {
      markdown += generateTocMarkdown(item.children, depth + 1)
    }
  }

  return markdown
}

async function convertMdxToMarkdown(
  slug: string[], 
  options: ConversionOptions = {}
): Promise<string> {
  const { includeSourceCode = true, includeInstallInstructions = true } = options
  
  try {
    // Read the MDX file
    const filePath = path.join(baseDir, CONTENT_DIRECTORY, slug.join("/")) + ".mdx"
    const source = fs.readFileSync(filePath, "utf8")
    
    // Extract frontmatter
    const frontmatterMatch = source.match(/^---\n([\s\S]*?)\n---/)
    const frontmatter = frontmatterMatch ? frontmatterMatch[1] : ""
    const content = source.replace(/^---\n[\s\S]*?\n---\n/, "")
    
    // Parse frontmatter
    const title = frontmatter.match(/title:\s*(.+)/)?.[1]?.replace(/['"]/g, "") || ""
    const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.replace(/['"]/g, "") || ""
    
    let markdown = ""
    
    // Add title and description
    if (title) {
      markdown += `# ${title}\n\n`
    }
    if (description) {
      markdown += `> ${description}\n\n`
    }
    
    // Convert MDX components to markdown
    let convertedContent = content
    
    // Convert ComponentPreview to markdown with demo source code
    convertedContent = convertedContent.replace(
      /<ComponentPreview\s+name="([^"]+)"[^>]*\/>/g,
      (match: string, name: string) => {
        try {
          // Try to load the demo source code from the registry
          const registryPath = path.join(baseDir, "public", "r", `${name}.json`)
          if (fs.existsSync(registryPath)) {
            const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"))
            const mainFile = registry.files.find(
              (file: any) => file.path.split("/").pop().replace(/\.(tsx|ts)$/, "") === name
                        )
            
            if (mainFile) {
              return `Example:\n\n\`\`\`tsx\n${mainFile.content}\n\`\`\`\n`
            }
          }
          // Fallback to link if demo source not found
          return `See the interactive demo at: [${name}](https://fancycomponents.dev/docs/components/${slug.join("/")})\n\n`
        } catch (error) {
          // Fallback to link if error loading demo source
          return `See the interactive demo at: [${name}](https://fancycomponents.dev/docs/components/${slug.join("/")})\n\n`
        }
      }
    )
    
    // Convert Link components to markdown links
    convertedContent = convertLinksToMarkdown(convertedContent)
    
    // Convert ComponentSource to markdown code block
    convertedContent = convertComponentSourceToMarkdown(convertedContent, includeSourceCode)
    
    // Convert InstallTabs to markdown
    convertedContent = convertInstallTabsToMarkdown(convertedContent, includeInstallInstructions)
    
    // Convert CodeSnippet to markdown code blocks
    convertedContent = convertCodeSnippetsToMarkdown(convertedContent)
    
    // Convert custom tables to markdown
    convertedContent = convertTablesToMarkdown(convertedContent)
    
    // Convert Tabs to markdown sections
    convertedContent = convertTabsToMarkdown(convertedContent)
    
    // Clean up any remaining JSX-style components
    convertedContent = cleanupRemainingComponents(convertedContent)
    
    // Generate table of contents
    const toc = generateTableOfContents(convertedContent)
    
    // Combine everything
    markdown += toc + convertedContent
    
    // Add footer
    markdown += `\n\n---\n\n*This documentation is also available in [interactive format](https://fancycomponents.dev/docs/components/${slug.join("/")}).*\n`
    
    return markdown.trim()
    
  } catch (error) {
    console.error("Error converting MDX to markdown:", error)
    throw new Error(`Failed to convert ${slug.join("/")} to markdown`)
  }
}

function convertLinksToMarkdown(content: string): string {
  // Convert Link components to markdown links
  return content.replace(
    /<Link\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/Link>/g,
    (match, href, linkText) => {
      // Clean the link text (remove any nested HTML)
      const cleanText = linkText.replace(/<[^>]*>/g, '').trim()
      
      // Add the base URL if it's a relative path
      let fullUrl = href
      if (href.startsWith('/')) {
        fullUrl = `https://fancycomponents.dev${href}.md`
      }
      
      return `[${cleanText}](${fullUrl})`
    }
  )
}

function convertComponentSourceToMarkdown(content: string, includeSourceCode: boolean): string {
  if (!includeSourceCode) {
    return content.replace(/<ComponentSource[^>]*\/>/g, "")
  }
  
  return content.replace(
    /<ComponentSource\s+name="([^"]+)"[^>]*\/>/g,
    (match, name) => {
      try {
        // Try to load the source code from the registry
        const registryPath = path.join(baseDir, "public", "r", `${name}.json`)
        if (fs.existsSync(registryPath)) {
          const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"))
          const mainFile = registry.files.find(
            (file: any) => file.path.split("/").pop().replace(/\.(tsx|ts)$/, "") === name
          )
          
          if (mainFile) {
            return `#### ${name}\n\n\`\`\`tsx\n${mainFile.content}\n\`\`\`\n\n`
          }
        }
        return `#### ${name}\n\nSource code for \`${name}\` component.\n\n`
      } catch (error) {
        return `#### ${name}\n\nSource code for \`${name}\` component.\n\n`
      }
    }
  )
}

function convertInstallTabsToMarkdown(content: string, includeInstructions: boolean): string {
  if (!includeInstructions) {
    return content.replace(/<InstallTabs[^>]*\/>/g, "")
  }
  
  return content.replace(
    /<InstallTabs\s+command="([^"]+)"[^>]*\/>/g,
    (match, command) => {
      const decodedCommand = command.replace(/&quot;/g, '"')
      
      // Check if it's a shadcn command and add npx prefix if needed
      if (decodedCommand.includes('shadcn add') && !decodedCommand.startsWith('npx')) {
        return `\`\`\`bash\nnpx ${decodedCommand}\n\`\`\`\n\n`
      }
      
      return `\`\`\`bash\n${decodedCommand}\n\`\`\`\n\n`
    }
  )
}

function convertCodeSnippetsToMarkdown(content: string): string {
  // Convert CodeSnippet components to regular markdown code blocks
  return content.replace(
    /<CodeSnippet\s+title="([^"]*)"[^>]*>\s*```(\w+)?\n([\s\S]*?)\n```\s*<\/CodeSnippet>/g,
    (match, title, language, code) => {
      const lang = language || "typescript"
      return `${title ? `### ${title}\n\n` : ""}\`\`\`${lang}\n${code}\n\`\`\`\n\n`
    }
  )
}

function convertTablesToMarkdown(content: string): string {
  // Convert JSX tables to proper markdown tables
  let result = content
  
  // Process each table block
  result = result.replace(/<Table[^>]*>[\s\S]*?<\/Table>/g, (tableBlock) => {
    // Extract header content
    const headerMatch = tableBlock.match(/<TableHeader[^>]*>([\s\S]*?)<\/TableHeader>/)
    const bodyMatch = tableBlock.match(/<TableBody[^>]*>([\s\S]*?)<\/TableBody>/)
    
    if (!headerMatch || !bodyMatch) {
      return tableBlock // Return original if can't parse
    }
    
    // Extract header cells
    const headerContent = headerMatch[1]
    const headerCells = extractTableCells(headerContent, 'TableHead')
    
    // Extract body rows
    const bodyContent = bodyMatch[1]
    const bodyRows = extractTableRows(bodyContent)
    
    if (headerCells.length === 0) {
      return tableBlock // Return original if no headers
    }
    
    // Build markdown table
    let markdownTable = ''
    
    // Header row
    markdownTable += '| ' + headerCells.join(' | ') + ' |\n'
    
    // Separator row
    markdownTable += '|' + headerCells.map(() => '----------|').join('') + '\n'
    
    // Body rows
    bodyRows.forEach(row => {
      if (row.length > 0) {
        // Pad row to match header length
        while (row.length < headerCells.length) {
          row.push('')
        }
        markdownTable += '| ' + row.join(' | ') + ' |\n'
      }
    })
    
    return markdownTable + '\n'
  })
  
  return result
}

function extractTableCells(content: string, cellType: string): string[] {
  const cellRegex = new RegExp(`<${cellType}[^>]*>([\\s\\S]*?)<\\/${cellType}>`, 'g')
  const cells: string[] = []
  let match
  
  while ((match = cellRegex.exec(content)) !== null) {
    // Clean the cell content
    let cellContent = match[1]
      .replace(/<[^>]*>/g, '') // Remove HTML tags
      .replace(/\s+/g, ' ') // Normalize whitespace
      .trim()
    
    cells.push(cellContent)
  }
  
  return cells
}

function extractTableRows(content: string): string[][] {
  const rowRegex = /<TableRow[^>]*>([\s\S]*?)<\/TableRow>/g
  const rows: string[][] = []
  let match
  
  while ((match = rowRegex.exec(content)) !== null) {
    const rowContent = match[1]
    const cells = extractTableCells(rowContent, 'TableCell')
    if (cells.length > 0) {
      rows.push(cells)
    }
  }
  
  return rows
}

function convertTabsToMarkdown(content: string): string {
  // Convert Tabs to markdown sections
  let result = content
  
  // Replace entire Tabs blocks with converted content
  result = result.replace(/<Tabs[^>]*>[\s\S]*?<\/Tabs>/g, (tabsBlock) => {
    // Extract TabsContent sections from this specific tabs block
    const tabsContentMatches = tabsBlock.match(/<TabsContent[^>]*value="([^"]+)"[^>]*>([\s\S]*?)<\/TabsContent>/g)
    
    if (!tabsContentMatches) return ''
    
    return tabsContentMatches.map(tabMatch => {
      const valueMatch = tabMatch.match(/value="([^"]+)"/)
      const contentMatch = tabMatch.match(/<TabsContent[^>]*>([\s\S]*?)<\/TabsContent>/)
      
      if (!valueMatch || !contentMatch) return ''
      
      const value = valueMatch[1]
      const content = contentMatch[1].trim()
      
      // Capitalize the first letter of the tab value
      const capitalizedValue = value.charAt(0).toUpperCase() + value.slice(1)
      
      return `### ${capitalizedValue}\n\n${content}\n\n`
    }).join('')
  })
  
  return result
}

function cleanupRemainingComponents(content: string): string {
  // Remove specific MDX components that don't have markdown equivalents
  // But preserve JSX components inside code blocks
  let result = content
  
  // Remove ExplanationDemo components
  result = result.replace(/<ExplanationDemo[^>]*\/>/g, "")
  
  // Split content by code blocks to avoid cleaning inside them
  const codeBlockRegex = /(```[\s\S]*?```)/g
  const parts = result.split(codeBlockRegex)
  
  // Clean up only the parts that are NOT code blocks (odd indices are code blocks)
  for (let i = 0; i < parts.length; i += 2) {
    // Only clean non-code-block parts
    if (parts[i]) {
      // Remove other known MDX components that we might have missed
      parts[i] = parts[i]
        .replace(/<Balancer[^>]*>/g, "")
        .replace(/<\/Balancer>/g, "")
        .replace(/<Link[^>]*>/g, "")
        .replace(/<\/Link>/g, "")
        .replace(/<TabsTrigger[^>]*>/g, "")
        .replace(/<\/TabsTrigger>/g, "")
        .replace(/<TabsList[^>]*>/g, "")
        .replace(/<\/TabsList>/g, "")
        // Clean up any remaining tab-related tags
        .replace(/<TabsContent[^>]*>/g, "")
        .replace(/<\/TabsContent>/g, "")
        
        // Clean up any remaining unknown single-tag components
        .replace(/<[A-Z][a-zA-Z]*[^>]*\/>/g, "")
    }
  }
  
  result = parts.join("")
  
  // Clean up extra whitespace
  result = result.replace(/\n\n\n+/g, "\n\n").trim()
  
  return result
}

function getAllMdxFiles(): string[][] {
  const contentDir = path.join(baseDir, CONTENT_DIRECTORY)
  const allFiles: string[][] = []
  
  function traverseDirectory(dir: string, currentPath: string[] = []) {
    const files = fs.readdirSync(dir)
    
    files.forEach((file: string) => {
      const filePath = path.join(dir, file)
      const stat = fs.statSync(filePath)
      
      if (stat.isDirectory()) {
        traverseDirectory(filePath, [...currentPath, file])
      } else if (file.endsWith('.mdx')) {
        const fileName = file.replace('.mdx', '')
        allFiles.push([...currentPath, fileName])
      }
    })
  }
  
  traverseDirectory(contentDir)
  return allFiles
}

async function buildMarkdownDocs() {
  console.log("Building markdown documentation...")
  
  // Ensure output directory exists
  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true })
  }
  
  // Get all MDX files
  const allMdxFiles = getAllMdxFiles()
  
  for (const slug of allMdxFiles) {
    try {
      console.log(`Converting: ${slug.join("/")}`)
      
      // Convert to markdown
      const markdown = await convertMdxToMarkdown(slug)
      
      // Create output directory structure
      const outputPath = path.join(outputDir, ...slug)
      const outputFileDir = path.dirname(outputPath)
      
      if (!fs.existsSync(outputFileDir)) {
        fs.mkdirSync(outputFileDir, { recursive: true })
      }
      
      // Write markdown file
      fs.writeFileSync(`${outputPath}.md`, markdown)
      
      console.log(`✓ Generated: ${slug.join("/")}.md`)
      
    } catch (error) {
      console.error(`✗ Failed to convert ${slug.join("/")}:`, error)
    }
  }
  
  console.log("Markdown documentation build completed!")
}

buildMarkdownDocs()
src/scripts/build-llms-txt.ts
파일 저장

// @ts-ignore
const fs = require("fs")
// @ts-ignore
const path = require("path")

const projectDir = path.join(__dirname, "..", "..")
const DOCS_DIRECTORY = "/src/content/docs/"
const outputPath = path.join(projectDir, "public", "llms.txt")

interface DocFile {
  path: string
  title: string
  description: string
  category: string
  slug: string
}

function getAllDocFiles(): DocFile[] {
  const contentDir = path.join(projectDir, DOCS_DIRECTORY)
  const allFiles: DocFile[] = []
  
  function traverseDirectory(dir: string, currentPath: string[] = []) {
    const files = fs.readdirSync(dir)
    
    files.forEach((file: string) => {
      const filePath = path.join(dir, file)
      const stat = fs.statSync(filePath)
      
      if (stat.isDirectory()) {
        traverseDirectory(filePath, [...currentPath, file])
      } else if (file.endsWith('.mdx')) {
        try {
          const source = fs.readFileSync(filePath, "utf8")
          
          // Extract frontmatter
          const frontmatterMatch = source.match(/^---\n([\s\S]*?)\n---/)
          const frontmatter = frontmatterMatch ? frontmatterMatch[1] : ""
          
          const title = frontmatter.match(/title:\s*(.+)/)?.[1]?.replace(/['"]/g, "") || ""
          const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.replace(/['"]/g, "") || ""
          
          if (title) {
            const fileName = file.replace('.mdx', '')
            const slug = [...currentPath, fileName].join("/")
            const category = currentPath.length > 0 ? currentPath[0] : "getting-started"
            
            allFiles.push({
              path: filePath,
              title,
              description,
              category,
              slug
            })
          }
        } catch (error) {
          console.error(`Error reading ${filePath}:`, error)
        }
      }
    })
  }
  
  traverseDirectory(contentDir)
  return allFiles
}

function categorizeFiles(files: DocFile[]) {
  const categories: Record<string, DocFile[]> = {}
  
  files.forEach(file => {
    if (!categories[file.category]) {
      categories[file.category] = []
    }
    categories[file.category].push(file)
  })
  
  // Sort files within each category
  Object.keys(categories).forEach(category => {
    categories[category].sort((a, b) => a.title.localeCompare(b.title))
  })
  
  return categories
}

function generateLlmsTxt(categories: Record<string, DocFile[]>): string {
  let llmsTxt = ""
  
  // Header
  llmsTxt += "# Fancy Components\n\n"
  
  // Description
  llmsTxt += "> A collection of fun and weird, ready-to-use components and microinteractions built with React, TypeScript, Tailwind CSS, and Motion (formerly Framer Motion).\n\n"
  
  // Overview
  llmsTxt += "Fancy Components aims to inject playfulness into web UI by providing copy-and-paste-able components inspired by award-winning sites. All components are open source and free to use for personal or commercial projects.\n\n"
  
  // Getting Started section
  if (categories["getting-started"]) {
    llmsTxt += "## Getting Started\n\n"
    categories["getting-started"].forEach(file => {
      const url = `https://fancycomponents.dev/docs/${file.slug}.md`
      llmsTxt += `- [${file.title}](${url})`
      if (file.description && file.description !== "null" && file.description.trim()) {
        llmsTxt += `: ${file.description}`
      }
      llmsTxt += "\n"
    })
    llmsTxt += "\n"
  }
  
  // Component categories
  const componentCategories = Object.keys(categories)
    .filter(cat => cat !== "getting-started")
    .sort()
  
  componentCategories.forEach(categoryKey => {
    const files = categories[categoryKey]
    if (files.length === 0) return
    
    // Format category name
    const categoryName = categoryKey.split('-')
      .map(word => word.charAt(0).toUpperCase() + word.slice(1))
      .join(' ')
    
    llmsTxt += `## ${categoryName}\n\n`
    
    files.forEach(file => {
      const url = `https://fancycomponents.dev/docs/${file.slug}.md`
      llmsTxt += `- [${file.title}](${url})`
      if (file.description && file.description !== "null" && file.description.trim()) {
        llmsTxt += `: ${file.description}`
      }
      llmsTxt += "\n"
    })
    llmsTxt += "\n"
  })
  
  // Additional Resources
  llmsTxt += "## Additional Resources\n\n"
  llmsTxt += "- [GitHub Repository](https://github.com/danielpetho/fancy): Source code and contributions\n"
  llmsTxt += "- [Interactive Documentation](https://fancycomponents.dev): Full documentation with live examples\n"
  llmsTxt += "- [Installation Guide](https://fancycomponents.dev/docs/installation.md): Setup instructions\n"
  llmsTxt += "- [Changelog](https://fancycomponents.dev/docs/changelog.md): Recent updates and new components\n\n"
  
  // Footer
  llmsTxt += "---\n\n"
  llmsTxt += "All components are available in both interactive format (for developers) and markdown format (for LLMs and documentation tools). "
  llmsTxt += "Simply append `.md` to any documentation URL to access the markdown version.\n"
  
  return llmsTxt
}

async function buildLlmsTxt() {
  console.log("Building llms.txt file...")
  
  try {
    // Get all documentation files
    const files = getAllDocFiles()
    console.log(`Found ${files.length} documentation files`)
    
    // Categorize files
    const categories = categorizeFiles(files)
    console.log(`Organized into categories: ${Object.keys(categories).join(", ")}`)
    
    // Generate llms.txt content
    const llmsTxtContent = generateLlmsTxt(categories)
    
    // Write to public folder
    fs.writeFileSync(outputPath, llmsTxtContent)
    
    console.log(`✓ Generated llms.txt with ${files.length} components`)
    console.log(`✓ File saved to: ${outputPath}`)
    
  } catch (error) {
    console.error("✗ Failed to build llms.txt:", error)
    throw error
  }
}

buildLlmsTxt() 
src/scripts/build-registry-index.ts
파일 저장

// @ts-ignore
const fs = require("fs")
// @ts-ignore
const path = require("path")

// @ts-ignore
const baseDir = path.join(__dirname, "..", "fancy")
const componentsDir = path.join(baseDir, "components")
const examplesDir = path.join(baseDir, "examples")
const hooksDir = path.join(__dirname, "..", "hooks")
const utilsDir = path.join(__dirname, "..", "utils")

type RegistryType =
  | "registry:ui"
  | "registry:block"
  | "registry:hook"
  | "registry:lib"

interface RegistryFile {
  path: string
  type: RegistryType
}

interface RegistryItem {
  name: string
  type: RegistryType
  files: RegistryFile[]
  registryDependencies?: string[]
  dependencies?: string[]
  component?: string
  devDependencies?: string[]
  tailwind?: string
  cssVars?: string
  author?: string
}

function getAuthor(componentName: string, type: "ui" | "example" | "hook" | "util"): string {
  const defaultAuthor = "daniel petho <https://www.danielpetho.com>"
  
  // Get the source file path based on type
  let sourceFilePath
  if (type === "example") {
    sourceFilePath = path.join(baseDir, "examples", "blocks", `${componentName}.tsx`)
  } else if (type === "ui") {
    sourceFilePath = path.join(baseDir, "components", "blocks", `${componentName}.tsx`)
  } else if (type === "hook") {
    sourceFilePath = path.join(__dirname, "..", "hooks", `${componentName}.ts`)
  } else if (type === "util") {
    sourceFilePath = path.join(__dirname, "..", "utils", `${componentName}.ts`)
  }

  if (sourceFilePath && fs.existsSync(sourceFilePath)) {
    const content = fs.readFileSync(sourceFilePath, "utf-8")
    const authorMatch = content.match(/\/\/\s*author:\s*([^<]+)<([^>]+)>/)
    if (authorMatch) {
      const [_, name, url] = authorMatch
      return `${name.trim()} <${url.trim()}>`
    }
  }

  return defaultAuthor
}

function findHookImports(sourceCode: string): string[] {
  // This will match lines such as:
  //   import useDetectBrowser from "@/hooks/use-detect-browser"
  //   import { useScreenSize } from "@/hooks/use-screen-size"
  //   import useDebounce, { useSomethingElse } from "@/hooks/use-debounce"
  const hookImportRegex = /import\s+[^'"]+\s+from\s+['"]@\/hooks\/([^'"]+)['"]/g
  const hooks: string[] = []
  let match

  while ((match = hookImportRegex.exec(sourceCode)) !== null) {
    // e.g. "use-detect-browser" from "@/hooks/use-detect-browser"
    const hookName = match[1].replace(/\.(ts|tsx)$/, "")
    hooks.push(hookName)
  }

  return hooks
}

function findComponentImports(sourceCode: string): string[] {
  // Match static imports from @/fancy/components/ or @/fancy/examples/
  const componentImportRegex =
    /import\s+([^'"]+?)\s+from\s+['"]@\/fancy\/(components|examples)\/([^'"]+)['"]/g
  const components: string[] = []
  let match

  while ((match = componentImportRegex.exec(sourceCode)) !== null) {
    const [_, importStatement, type, componentPath] = match

    // Handle the path for all imports from this line
    const basePath = componentPath
      .replace(/\.(ts|tsx)$/, "")
      .replace(/([A-Z])/g, "-$1")
      .toLowerCase()
      .replace(/^-/, "")

    // no self-reference
    const currentComponent = path
      .basename(componentPath, path.extname(componentPath))
      .replace(/([A-Z])/g, "-$1")
      .toLowerCase()
      .replace(/^-/, "")

    if (basePath !== currentComponent) {
      components.push(`fancy/${basePath}`)
    }
  }

  // Collect dynamic imports as well
  components.push(...findDynamicComponentImports(sourceCode))

  // Remove duplicates
  return Array.from(new Set(components))
}

// ---------------------------------------------------------------------------
// helper to detect dynamic imports, e.g. dynamic(() => import("@/fancy/..."))
function findDynamicComponentImports(sourceCode: string): string[] {
  // Looks for lines like: dynamic(() => import("@/fancy/components/..."))
  // Capture the part after "@/fancy/{components|examples}/"
  const dynamicImportRegex =
    /dynamic\(\s*\(\)\s*=>\s*import\(\s*['"]@\/fancy\/(components|examples)\/([^'"]+)['"]\s*\)/g
  const dynComponents: string[] = []
  let match

  while ((match = dynamicImportRegex.exec(sourceCode)) !== null) {
    const [_, type, componentPath] = match
    const componentName = componentPath
      .replace(/\.(ts|tsx)$/, "")
      .replace(/([A-Z])/g, "-$1")
      .toLowerCase()
      .replace(/^-/, "")
    dynComponents.push(`fancy/${componentName}`)
  }

  return dynComponents
}

// ---------------------------------------------------------------------------

function findExternalDependencies(sourceCode: string): string[] {
  // Match all imports that:
  // - Don't start with @/
  // - Don't start with react or next (ignore react, react-dom, next, etc)
  // - Don't start with ./ or ../
  const externalImportRegex = /from\s+['"]([^'"@\./][^'"]+)['"]/g
  const dependencies = new Set<string>()
  let match

  while ((match = externalImportRegex.exec(sourceCode)) !== null) {
    const [_, importPath] = match
    // Get the package name (everything before any / character)
    const packageName = importPath.split("/")[0]

    // Skip react-related and next-related packages
    if (!packageName.startsWith("react") && !packageName.startsWith("next")) {
      dependencies.add(packageName)
    }
  }

  return Array.from(dependencies)
}

function findUtilImports(sourceCode: string): string[] {
  // Match imports from @/utils/
  const utilImportRegex =
    /import\s+{?[^}]*}?\s+from\s+['"]@\/utils\/([^'"]+)['"]/g
  const utils: string[] = []
  let match

  while ((match = utilImportRegex.exec(sourceCode)) !== null) {
    const utilPath = match[1].replace(/\.(ts|tsx)$/, "")
    utils.push(utilPath)
  }

  return utils
}

function getAdditionalConfig(filePath: string): any {
  const dir = path.dirname(filePath)
  const baseName = path.basename(filePath, path.extname(filePath))
  const configPath = path.join(dir, `${baseName}.json`)

  if (fs.existsSync(configPath)) {
    try {
      return JSON.parse(fs.readFileSync(configPath, "utf-8"))
    } catch (error) {
      console.warn(`Error reading config for ${baseName}:`, error)
    }
  }
  return null
}

function generateRegistryItem(
  filePath: string,
  type: "ui" | "example" | "hook" | "util",
  allHooks: Record<string, string>
  // @ts-ignore
): RegistryItem | null {
  // Get the relative path from the components or examples directory
  const baseDirectory =
    type === "hook"
      ? hooksDir
      : type === "example"
        ? examplesDir
        : componentsDir

  const relativePath = path.relative(baseDirectory, filePath)
  const sourceCode = fs.readFileSync(filePath, "utf-8")

  const name = path
    .basename(filePath, path.extname(filePath))
    .replace(/([A-Z])/g, "-$1")
    .toLowerCase()
    .replace(/^-/, "")

  // Construct the import path with the correct directory structure
  const basePath =
    type === "hook"
      ? "@/hooks/"
      : type === "example"
        ? "@/fancy/examples/"
        : "@/fancy/components/"
  const importPath = `${basePath}${relativePath}`.replace(/\\/g, "/")
  const importPathWithoutExt = importPath.replace(/\.tsx?$/, "")

  const getSimplifiedPath = (
    originalPath: string,
    itemType: "ui" | "example" | "hook" | "util"
  ) => {
    // Get the relative path from the base directory
    const relativePath = path
      .relative(
        itemType === "hook"
          ? hooksDir
          : itemType === "example"
            ? examplesDir
            : itemType === "util"
              ? utilsDir
              : componentsDir,
        originalPath
      )
      .replace(/\\/g, "/")

    // Remove the file extension
    const pathWithoutExt = relativePath.replace(/\.(ts|tsx)$/, "")

    switch (itemType) {
      case "hook":
        return `hooks/${pathWithoutExt}`
      case "example":
        return `examples/${pathWithoutExt}`
      case "ui":
        return `fancy/${pathWithoutExt}`
      case "util":
        return `utils/${pathWithoutExt}`
    }
  }

  const files: RegistryFile[] = [
    {
      path: getSimplifiedPath(filePath, type),
      type:
        type === "hook"
          ? "registry:hook"
          : type === "example"
            ? "registry:block"
            : type === "util"
              ? "registry:lib"
              : "registry:ui",
    },
  ]

  const registryDependencies: string[] = []

  // ADD the discovered hooks
  if (type !== "hook") {
    const usedHooks = findHookImports(sourceCode)
    usedHooks.forEach((hookName) => {
      registryDependencies.push(`hooks/${hookName}`)
    })
  }

  // Find component dependencies
  const externalDeps = new Set(findExternalDependencies(sourceCode))

  // Find component dependencies
  const componentDeps = findComponentImports(sourceCode)
  componentDeps.forEach((dep) => {
    registryDependencies.push(dep)
  })

  // Handle utils dependencies
  const utilDeps = findUtilImports(sourceCode)
  if (utilDeps.length > 0) {
    utilDeps.forEach((utilPath) => {
      registryDependencies.push(`utils/${utilPath}`)

      // Add dependencies from utils
      const utilFilePath = path.join(utilsDir, `${utilPath}.ts`)
      if (fs.existsSync(utilFilePath)) {
        const utilCode = fs.readFileSync(utilFilePath, "utf-8")
        const utilExternalDeps = findExternalDependencies(utilCode)
        utilExternalDeps.forEach((dep) => externalDeps.add(dep))
      }
    })
  }

  const additionalConfig = getAdditionalConfig(filePath)

  // Add additional dependencies if they exist
  if (additionalConfig?.additionalDependencies) {
    additionalConfig.additionalDependencies.forEach((dep: string) => {
      externalDeps.add(dep)
    })
  }

  const item: RegistryItem = {
    name,
    type:
      type === "hook"
        ? "registry:hook"
        : type === "example"
          ? "registry:block"
          : type === "util"
            ? "registry:lib"
            : "registry:ui",
    files,
    author: getAuthor(name, type),
    ...(registryDependencies.length > 0 && {
      registryDependencies: registryDependencies,
    }),
    ...(externalDeps.size > 0 || additionalConfig?.devDependencies
      ? {
          dependencies: [
            ...Array.from(externalDeps),
            ...(additionalConfig?.devDependencies || []),
          ],
        }
      : null),
    ...(additionalConfig?.tailwind && {
      tailwind: additionalConfig.tailwind,
    }),
    ...(additionalConfig?.cssVars && {
      cssVars: additionalConfig.cssVars,
    }),
    ...(type !== "hook" &&
      type !== "util" && {
        component: `React.lazy(\n      () => import('${importPathWithoutExt}') \n)`,
      }),
  }

  return item
}

function buildHooksMap(): Record<string, string> {
  const hooksMap: Record<string, string> = {}

  function traverseHooks(dir: string) {
    const files = fs.readdirSync(dir)

    files.forEach((file: any) => {
      const filePath = path.join(dir, file)
      const stat = fs.statSync(filePath)

      if (stat.isDirectory()) {
        traverseHooks(filePath)
      } else if (file.match(/\.(ts|tsx)$/)) {
        const hookName = path.basename(file, path.extname(file))
        hooksMap[hookName] = `hooks/${hookName}` // Simplified hook path
      }
    })
  }

  traverseHooks(hooksDir)
  return hooksMap
}

const hooksMap = buildHooksMap()

function traverseDirectory(
  dir: string,
  type: "ui" | "example" | "hook" | "util"
): Record<string, RegistryItem> {
  const registry: Record<string, RegistryItem> = {}

  function traverse(currentDir: string) {
    const files = fs.readdirSync(currentDir)

    files.forEach((file: string) => {
      const filePath = path.join(currentDir, file)
      const stat = fs.statSync(filePath)

      // Skip _helpers folder in utils
      if (type === "util" && file === "_helpers") {
        return
      }

      if (stat.isDirectory()) {
        traverse(filePath)
      } else if (file.match(/\.(tsx|ts)$/)) {
        const item = generateRegistryItem(filePath, type, hooksMap)
        if (item) {
          registry[item.name] = item
        }
      }
    })
  }

  traverse(dir)
  return registry
}

// Generate both registries
const fancy = traverseDirectory(componentsDir, "ui")
const example = traverseDirectory(examplesDir, "example")
const hooks = traverseDirectory(hooksDir, "hook")
const utils = traverseDirectory(utilsDir, "util")

// Generate the final index.ts content
const content = `import * as React from "react";
import { Registry } from "@/fancy/schema";

// This file is generated automatically. Do not edit it manually.

const fancy: Registry = ${JSON.stringify(fancy, null, 2)};

const example: Registry = ${JSON.stringify(example, null, 2)};

const hooks: Registry = ${JSON.stringify(hooks, null, 2)};

const utils: Registry = ${JSON.stringify(utils, null, 2)};

export const registry = {
  ...fancy,
  ...example,
  ...hooks,
  ...utils,
};
`

// Replace double quotes with single quotes and fix the React.lazy imports
const formattedContent = content
  .replace(/"component": "(.*?)"/g, "component: $1")
  .replace(/\\n/g, "\n")
  .replace(/\s+\)/g, ")")
  .replace(/"\{/g, "{")
  .replace(/\}"/g, "}")
  .replace(/\\"/g, '"')

// Write the file
fs.writeFileSync(path.join(baseDir, "index.ts"), formattedContent)
console.log("Registry file generated successfully!")

// Create a clean version of the registry for JSON
function createCleanRegistry(registry: any) {
  const cleanRegistry = { ...registry }

  // Remove React.lazy components from all entries
  Object.values(cleanRegistry).forEach((item: any) => {
    if (item.component) {
      delete item.component
    }
  })

  return cleanRegistry
}

// Generate and write the index.json file
const cleanRegistry = {
  ...createCleanRegistry(fancy),
  ...createCleanRegistry(example),
  ...createCleanRegistry(hooks),
  ...createCleanRegistry(utils),
}

// Convert the flat registry to shadcn schema format
const registryItems = Object.values(cleanRegistry).map((item: any) => {
  // Add title and description if they don't exist
  const title = item.title || item.name
    .split('-')
    .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ')
  
  const description = item.description || `A ${item.type.replace('registry:', '')} component.`
  
  return {
    name: item.name,
    type: item.type,
    title,
    description,
    ...(item.registryDependencies && { registryDependencies: item.registryDependencies }),
    ...(item.dependencies && { dependencies: item.dependencies }),
    ...(item.devDependencies && { devDependencies: item.devDependencies }),
    ...(item.tailwind && { tailwind: item.tailwind }),
    ...(item.cssVars && { cssVars: item.cssVars }),
    ...(item.author && { author: item.author }),
    files: item.files || []
  }
})

// Create the shadcn schema format
const shadcnRegistry = {
  "$schema": "https://ui.shadcn.com/schema/registry.json",
  "name": "fancy",
  "homepage": "https://fancycomponents.dev",
  "items": registryItems
}

const jsonOutputDir = path.join(__dirname, "..", "..", "public/r")
if (!fs.existsSync(jsonOutputDir)) {
  fs.mkdirSync(jsonOutputDir, { recursive: true })
}

fs.writeFileSync(
  path.join(jsonOutputDir, "registry.json"),
  JSON.stringify(shadcnRegistry, null, 2)
)

console.log("Registry files generated successfully!")
src/scripts/build-registry-sources.ts
파일 저장

// @ts-ignore
const fs = require("fs")
// @ts-ignore
const path = require("path")

// @ts-ignore
const baseDir = path.join(__dirname, "..", "..")
const registryJsonPath = path.join(baseDir, "public/r/registry.json")

// Single output directory for all registry items
const registryOutputDir = path.join(baseDir, "public/r")

// Get the category from the file path (the directory structure)
const getCategory = (filePath: string) => {
  const parts = filePath.split("/")
  // Remove the filename and get the parent directory if it exists
  parts.pop()
  return parts.length > 0 ? parts[parts.length - 1] : ""
}

// Ensure output directory exists
if (!fs.existsSync(registryOutputDir)) {
  fs.mkdirSync(registryOutputDir, { recursive: true })
}

// ---------------------------------------------------------------------------
// Helper: Safely read file content
function getSourceContent(filePath: string): string {
  try {
    if (!fs.existsSync(filePath)) {
      console.warn(`File does not exist: ${filePath}`)
      return ""
    }
    return fs.readFileSync(filePath, "utf-8")
  } catch (error) {
    console.error(`Error reading file ${filePath}:`, error)
    return ""
  }
}

  // Transform @/fancy/components and @/fancy/examples imports to @/components/fancy/... 
  // so it will work with open in v0
function transformImportPaths(content: string): string {
  let newContent = content
  
  // Match import statements with @/fancy/components or @/fancy/examples
  // Handles patterns like:
  // - import Foo from "@/fancy/components/text/bar"
  // - import { Bar } from '@/fancy/examples/blocks/baz'
  // - Multi-line imports like:
  //   import Foo, { 
  //     Bar, 
  //     Baz 
  //   } from "@/fancy/components/something"
  const importRegex = /import\s+([\s\S]+?)\s+from\s+['"]@\/fancy\/(components|examples)(\/[^'"]*)?['"]/g
  
  newContent = newContent.replace(importRegex, (match, importPart, type, subPath) => {
    // Transform: 
    // - @/fancy/components/text/something -> @/components/fancy/text/something
    // - @/fancy/examples/carousel/demo -> @/components/fancy/carousel/demo
    const newPath = subPath ? `@/components/fancy${subPath}` : '@/components/fancy'
    return `import ${importPart} from "${newPath}"`
  })
  
  return newContent
}

function resolveColorInContent(content: string): string {
  const colorMappings = {
    "primary-red": "#ff5941",
    "primary-orange": "#f97316",
    "primary-pink": "#e794da",
    "primary-blue": "#0015ff",
    teal: "#1f464d",
    "teal-foreground": "#3bb6ab",
    yellow: "#eab308",
    "yellow-foreground": "#ffd726",
  }

  // Replace color classes with their hex values
  let newContent = content

  // Handle the container transformation first (before color replacements). this is for v0.
  // Look for the first occurrence of a container with w-full h-full
  const containerRegex =
    /(<(?:div|section)[^>]*\bclass(?:Name)?=["'](?:[^"']*\s)?)(w-full\s+h-full|h-full\s+w-full)(\s[^"']*["'][^>]*>)/i
  newContent = newContent.replace(
    containerRegex,
    (match, prefix, dimensions, suffix) => {
      return `${prefix}w-dvw h-dvh${suffix}`
    }
  )

  // Handle basic color classes (bg-red, text-red, etc.)
  Object.entries(colorMappings).forEach(([color, hex]) => {
    // Match patterns like bg-red, text-red, border-red, etc.
    const regex = new RegExp(
      `(bg|text|border|ring-3|outline|fill|stroke)-${color}(?![\\w-])`,
      "g"
    )
    newContent = newContent.replace(regex, `$1-[${hex}]`)
  })

  // Handle opacity modifiers (bg-red/50, text-red/75, etc.)
  Object.entries(colorMappings).forEach(([color, hex]) => {
    const opacityRegex = new RegExp(
      `(bg|text|border|ring-3|outline|fill|stroke)-${color}/([0-9]+)`,
      "g"
    )
    newContent = newContent.replace(opacityRegex, (_, prefix, opacity) => {
      const alpha = parseInt(opacity) / 100
      return `${prefix}-[${hex}${alpha.toString(16).padStart(2, "0")}]`
    })
  })

  return newContent
}

// ---------------------------------------------------------------------------
// "file => { path, content, target }"
function processItemFiles(registryItem: any): any[] {
  const out: any[] = []
  if (!registryItem.files) return out

  registryItem.files.forEach((file: any) => {
    // // skip any _helpers folder
    // if (file.path.includes('_helpers')) {
    //   return
    // }

    let sourceFilePath = ""
    const fileName = file.path.split("/").pop()

    if (file.type === "registry:hook") {
      const hookPath = file.path.replace("hooks/", "")
      sourceFilePath = path.join(baseDir, "src", "hooks", `${hookPath}.ts`)
    } else if (file.type === "registry:ui") {
      const componentPath = file.path.replace("fancy/", "")
      sourceFilePath = path.join(
        baseDir,
        "src",
        "fancy",
        "components",
        `${componentPath}.tsx`
      )
    } else if (file.type === "registry:block") {
      const examplePath = file.path.replace("examples/", "")
      sourceFilePath = path.join(
        baseDir,
        "src",
        "fancy",
        "examples",
        `${examplePath}.tsx`
      )
    } else if (file.type === "registry:lib") {
      const utilPath = file.path.replace("utils/", "")
      sourceFilePath = path.join(baseDir, "src", "utils", `${utilPath}.ts`)
    }

    if (!sourceFilePath) return

    let content = getSourceContent(sourceFilePath)

    // Apply import path transformations to all content
    content = transformImportPaths(content)

    // Add appropriate extension for the path
    let extension =
      file.type === "registry:ui" || file.type === "registry:block"
        ? ".tsx"
        : ".ts"
    const pathWithExt = file.path.startsWith("/")
      ? file.path + extension
      : `${file.path}${extension}`

    // We also compute the "target" path as your original code does
    let targetPath = ""
    if (file.type === "registry:hook") {
      targetPath = `hooks/${fileName}.ts`
    } else if (file.type === "registry:ui") {
      const category = getCategory(file.path.replace("fancy/", ""))
      targetPath = category
        ? `components/fancy/${category}/${fileName}.tsx`
        : `components/fancy/${fileName}.tsx`
    } else if (file.type === "registry:block") {
      const examplePath = file.path.replace("examples/", "")
      const category = getCategory(examplePath)
      targetPath = category
        ? `components/fancy/${category}/${fileName}.tsx`
        : `components/fancy/${fileName}.tsx`
    } else if (file.type === "registry:lib") {
      const utilPath = file.path.replace("utils/", "")
      const category = getCategory(utilPath)
      targetPath = category
        ? `utils/${category}/${fileName}.ts`
        : `utils/${fileName}.ts`
    }

    // Only resolve colors for block registry items
    if (file.type === "registry:block") {
      content = resolveColorInContent(content)
    }

    console.log(targetPath)

    out.push({
      path: pathWithExt,
      content,
      type: file.type,
      target: targetPath,
    })
  })

  return out
}

// ---------------------------------------------------------------------------
// A function to recursively collect *all* files from a given registry
// item name, including that item's own files plus all of its nested
// registryDependencies. We gather them in an array so we can feed them
// into the final "files" for a block.
function gatherAllDependencyFiles(
  itemName: string,
  registry: Record<string, any>,
  visited = new Set<string>()
): any[] {
  if (!registry[itemName]) return []
  // If we've already processed this item, skip to avoid duplicates / loops:
  if (visited.has(itemName)) return []
  visited.add(itemName)

  const item = registry[itemName]
  let allFiles: any[] = []

  // 1) Collect *this* item's own files
  const processedFiles = processItemFiles(item)
  allFiles.push(...processedFiles)

  // 2) Recursively gather sub-dependencies (registryDependencies)
  if (item.registryDependencies) {
    item.registryDependencies.forEach((depUrl: string) => {
      // depUrl is e.g. 'https://fancycomponents.dev/r/gravity.json'
      // we want just "gravity"
      const depName = depUrl.split("/").pop()?.replace(".json", "")
      if (depName && registry[depName]) {
        const subFiles = gatherAllDependencyFiles(depName, registry, visited)
        allFiles.push(...subFiles)
      }
    })
  }

  // 3) NEW: Also check imports in each file's content for additional dependencies
  processedFiles.forEach((file) => {
    const imports = parseImports(file.content)
    imports.forEach((importPath) => {
      // Look for imports from @/hooks or @/utils
      if (
        importPath.startsWith("@/hooks/") ||
        importPath.startsWith("@/utils/")
      ) {
        const depName = importPath.split("/").pop()
        if (depName && registry[depName] && !visited.has(depName)) {
          const subFiles = gatherAllDependencyFiles(depName, registry, visited)
          allFiles.push(...subFiles)
        }
      }
    })
  })

  return allFiles
}

// ---------------------------------------------------------------------------
// Helper to parse import statements from file content.
// We'll match lines like: import foo from "..."
function parseImports(content: string): string[] {
  const regex = /import\s+[^"'\n]+?\s+from\s+['"]([^'"]+)['"]/g
  const imports: string[] = []
  let match
  while ((match = regex.exec(content)) !== null) {
    imports.push(match[1])
  }
  return imports
}

// ---------------------------------------------------------------------------
// This now builds a single item's .json file
function processRegistryItem(name: string, item: any): any {
  const output: any = {
    $schema: "https://ui.shadcn.com/schema/registry-item.json",
    name,
    type: item.type,
    title: item.title,
    description: item.description,
    dependencies: item.dependencies || [],
    author: item.author, // Add this line
  }

   // Collect direct registryDependencies
   const registryDeps = new Set<string>()
   if (item.registryDependencies) {
     item.registryDependencies.forEach((dep: string) => {
       // Don't add self as dependency
       const fileName = dep.split("/").pop()
       if (fileName !== name) {
         registryDeps.add(`https://fancycomponents.dev/r/${fileName}.json`)
       }
     })
   }

  // Also add hooks/libs from item.files
  item.files.forEach((f: any) => {
    if (f.type === "registry:hook" || f.type === "registry:lib") {
      const fileName = f.path.split("/").pop()
      if (fileName !== name) {
        registryDeps.add(`https://fancycomponents.dev/r/${fileName}.json`)
      }
    }
  })

  // -------------------------------------------------------------------------
  // Gather all *actual* files for this item, including dependencies:
  const registry = JSON.parse(fs.readFileSync(registryJsonPath, "utf-8"))
  //let allFiles = gatherAllDependencyFiles(name, registry)

  const allFiles = processItemFiles(item)


   // NEW: Detect in-file imports and handle them dynamically:
   allFiles.forEach((file) => {
    const imports = parseImports(file.content)
    imports.forEach((importPath) => {
      // Handle shadcn components
      if (importPath.startsWith("@/components/ui/")) {
        const componentName = importPath.split("/").pop() || ""
        if (componentName !== name) {
          registryDeps.add(componentName)
        }
        return
      }

      // Handle local references (hooks, utils, components)
      const possibleName = importPath.split("/").pop() || ""
      
      // Read and parse registry with schema format support
      const registryData = JSON.parse(fs.readFileSync(registryJsonPath, "utf-8"))
      let registryMap: any
      if (registryData.items) {
        registryMap = {}
        registryData.items.forEach((item: any) => {
          registryMap[item.name] = item
        })
      } else {
        registryMap = registryData
      }
      
      if (registryMap[possibleName] && possibleName !== name) {
        registryDeps.add(`https://fancycomponents.dev/r/${possibleName}.json`)
      }
    })
  })

  // // Also update registryDependencies to include all discovered dependencies
  // allFiles.forEach((file) => {
  //   const imports = parseImports(file.content)
  //   imports.forEach((importPath) => {
  //     if (
  //       importPath.startsWith("@/hooks/") ||
  //       importPath.startsWith("@/utils/")
  //     ) {
  //       const depName = importPath.split("/").pop()
  //       if (depName && registry[depName]) {
  //         registryDeps.add(`https://fancycomponents.dev/r/${depName}.json`)
  //       }
  //     }
  //   })
  // })

  if (registryDeps.size > 0) {
    output.registryDependencies = Array.from(registryDeps)
  }

  // Add devDependencies
  if (item.devDependencies) {
    output.devDependencies = item.devDependencies
  }
  // Add tailwind config
  if (item.tailwind && Object.keys(item.tailwind.config || {}).length > 0) {
    output.tailwind = item.tailwind
  }

  // Add cssVars
  if (item.cssVars && Object.keys(item.cssVars).length > 0) {
    output.cssVars = item.cssVars
  }

  // Remove duplicates by path in allFiles:
  const uniqueMap = new Map<string, any>()
  allFiles.forEach((f) => {
    uniqueMap.set(f.path, f)
  })
  output.files = Array.from(uniqueMap.values())

  // add cssVars for blocks
  if (item.type === "registry:block") {
    output.cssVars = {
      ...output.cssVars,
      ":root": {
        red: "#ff5941",
        orange: "#f97316",
        pink: "#e794da",
        blue: "#0015ff",
        teal: "#1f464d",
        "teal-foreground": "#3bb6ab",
        yellow: "#eab308",
        "yellow-foreground": "#ffd726",
        "primary-red": "var(--red)",
        "primary-orange": "var(--orange)",
        "primary-pink": "var(--pink)", 
        "primary-blue": "var(--blue)",
      },
    }

    // Merge existing tailwind config with our new colors
    if (!output.tailwind) {
      output.tailwind = { config: { theme: { extend: {} } } }
    }
    if (!output.tailwind.config) {
      output.tailwind.config = { theme: { extend: {} } }
    }
    if (!output.tailwind.config.theme) {
      output.tailwind.config.theme = { extend: {} }
    }
    if (!output.tailwind.config.theme.extend) {
      output.tailwind.config.theme.extend = {}
    }
  }

  return output
}

// ---------------------------------------------------------------------------
// Build the source files for all items
function buildSourceFiles() {
  // Read the registry
  const registryData = JSON.parse(fs.readFileSync(registryJsonPath, "utf-8"))
  
  // Handle both old and new schema formats
  let registry: any
  if (registryData.items) {
    // New shadcn schema format
    registry = {}
    registryData.items.forEach((item: any) => {
      registry[item.name] = item
    })
  } else {
    // Old format (fallback)
    registry = registryData
  }

  // Process items in order: utils -> hooks -> ui -> blocks
  const processItemsByType = (type: string) => {
    Object.entries(registry).forEach(([name, item]: [string, any]) => {
      if (item.type === type) {
        const sourceFile = processRegistryItem(name, item)
        const outputPath = path.join(registryOutputDir, `${name}.json`)
        fs.writeFileSync(outputPath, JSON.stringify(sourceFile, null, 2))
        console.log(`Generated source file for: ${name}`)
      }
    })
  }

  processItemsByType("registry:lib") // Utils first
  processItemsByType("registry:hook") // Then hooks
  processItemsByType("registry:ui") // Then UI components
  processItemsByType("registry:block") // Finally blocks/demos

  console.log("Source files generation completed.")
}

buildSourceFiles()
src/styles/docsearch.css
파일 저장

/* Custom DocSearch styles */
:root {
  /* Primary colors and text */
  --docsearch-primary-color: var(--muted); /* Default: #5468ff */
  --docsearch-text-color: var(--foreground); /* Default: #1c1e21 */
  --docsearch-muted-color: var(--muted-foreground); /* Default: #969faf */
  --docsearch-logo-color: var(--color-blue); /* Default: #5468ff */
  --docsearch-highlight-color: var(
    --color-blue
  ); /* Default: var(--docsearch-primary-color) */
  --docsearch-icon-color: var(
    --color-blue
  ); /* Default: var(--docsearch-muted-color) */

  /* Spacing and sizing */
  --docsearch-spacing: 12px; /* Default: 12px */
  --docsearch-icon-stroke-width: 1.1; /* Default: 1.4 */
  --docsearch-modal-width: 560px; /* Default: 560px */
  --docsearch-modal-height: 600px; /* Default: 600px */
  --docsearch-hit-height: 44px; /* Default: 56px */
  --docsearch-footer-height: 44px; /* Default: 44px */
  --docsearch-vh: 1vh; /* Default: 1vh - Used for mobile height calculations */

  /* Modal styling */
  --docsearch-container-background: rgba(
    150,
    150,
    150,
    0.5
  ); /* Default: rgba(101, 108, 133, 0.8) */
  --docsearch-modal-shadow: none; /* Default: inset 1px 1px 0 0 hsla(0, 0%, 100%, 0.5), 0 3px 8px 0 #555a64 */

  /* Searchbox styling */
  --docsearch-searchbox-background: var(--input); /* Default: #ebedf0 */
  --docsearch-searchbox-focus-background: #fff; /* Default: #fff */
  --docsearch-searchbox-shadow: none; /* Default: inset 0 0 0 2px var(--docsearch-primary-color) */

  /* Hit (search result) styling */

  /* Button specific styling */
  --docsearch-button-border-radius: 0.6rem; /* Default: 40px - Border radius of the search button */
  --docsearch-button-margin: 0 0 0 16px; /* Default: 0 0 0 16px - Margin of the search button */
  --docsearch-button-padding: 0 8px; /* Default: 0 8px - Padding of the search button */
  --docsearch-button-key-width: 20px; /* Default: 20px - Width of keyboard shortcut keys */
  --docsearch-button-key-height: 18px; /* Default: 18px - Height of keyboard shortcut keys */
  --docsearch-button-key-border-radius: 3px; /* Default: 3px - Border radius of keyboard shortcut keys */
}

.dark {
  --docsearch-container-background: rgba(
    50,
    50,
    50,
    0.5
  );
}

/* Modal styling overrides */
.DocSearch--active {
  overflow: hidden !important;
}

.DocSearch-Container,
.DocSearch-Container * {
  box-sizing: border-box;
}

.DocSearch-Container {
  background-color: var(--docsearch-container-background);
  backdrop-filter: blur(8px);
  height: 100vh;
  left: 0;
  position: fixed;
  top: 0;
  width: 100vw;
  z-index: 200;
}

.DocSearch-Container a {
  text-decoration: none;
}

.DocSearch-Link {
  appearance: none;
  background: none;
  border: 0;
  color: var(--red);
  cursor: pointer;
  font: inherit;
  margin: 0;
  padding: 0;
}

.DocSearch-Modal {
  background: var(--background);
  border-radius: 1rem;
  border: 1px solid hsl(var(--border));
  display: flex;
  flex-direction: column;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  max-width: var(--docsearch-modal-width);
  min-width: var(--docsearch-modal-width);
  height: 520px;
  max-height: 80vh;
  margin: 0;
}

.DocSearch-SearchBar {
  display: flex;
  padding: 0;
}

.DocSearch-Form {
  align-items: center;
  background: var(--background);
  display: flex;
  font-size: 0.8em;
  height: 48px;
  margin: 0;
  padding: 0 12px;
  position: relative;
  border-bottom: 1px solid hsl(var(--border));
  border-top-left-radius: 1rem;
  border-top-right-radius: 1rem;
  width: 100%;
}

.DocSearch-Input {
  appearance: none;
  background: transparent;
  color: var(--foreground);
  flex: 1;
  font: inherit;
  font-size: 1.2em;
  height: 100%;
  outline: none;
  padding: 0 0 0 8px;
  /* border-radius: 1rem; */
  width: 100%;
}

.DocSearch-Input::placeholder {
  color: hsl(var(--muted-foreground));
  opacity: 1;
}

.DocSearch-Input::-webkit-search-cancel-button,
.DocSearch-Input::-webkit-search-decoration,
.DocSearch-Input::-webkit-search-results-button,
.DocSearch-Input::-webkit-search-results-decoration {
  display: none;
}

.DocSearch-LoadingIndicator,
.DocSearch-MagnifierLabel,
.DocSearch-Reset {
  margin: 0;
  padding: 0;
}

.DocSearch-MagnifierLabel,
.DocSearch-Reset {
  align-items: center;
  color: hsl(var(--muted-foreground));
  display: flex;
  justify-content: center;
}

.DocSearch-Container--Stalled .DocSearch-MagnifierLabel,
.DocSearch-LoadingIndicator {
  display: none;
}

.DocSearch-Container--Stalled .DocSearch-LoadingIndicator {
  align-items: center;
  color: var(--foreground);
  display: flex;
  justify-content: center;
}

@media screen and (prefers-reduced-motion: reduce) {
  .DocSearch-Reset {
    animation: none;
    appearance: none;
    background: none;
    border: 0;
    border-radius: 50%;
    color: var(--foreground);
    cursor: pointer;
    right: 0;
    stroke-width: var(--docsearch-icon-stroke-width);
  }
}

.DocSearch-Reset {
  animation: fade-in 0.1s ease-in forwards;
  appearance: none;
  background: none;
  border: 0;
  border-radius: 50%;
  color: var(--foreground);
  cursor: pointer;
  padding: 2px;
  right: 0;
  stroke-width: var(--docsearch-icon-stroke-width);
}

.DocSearch-Reset[hidden] {
  display: none;
}

.DocSearch-Reset:hover {
  color: hsl(var(--muted-foreground));
}

.DocSearch-LoadingIndicator svg,
.DocSearch-MagnifierLabel svg {
  height: 16px;
  width: 16px;
}

.DocSearch-Cancel {
  display: none;
}

.DocSearch-Dropdown {
  flex: 1;
  overflow-y: auto;
  padding: 0 var(--docsearch-spacing);
  scrollbar-color: var(--muted) var(--docsearch-modal-background);
  scrollbar-width: thin;
}

.DocSearch-Dropdown::-webkit-scrollbar {
  width: 8px;
}

.DocSearch-Dropdown::-webkit-scrollbar-track {
  background: transparent;
}

.DocSearch-Dropdown::-webkit-scrollbar-thumb {
  background-color: hsl(var(--muted));
  border-radius: 20px;
}

.DocSearch-Dropdown ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.DocSearch-Label {
  font-size: 0.75em;
  color: hsl(var(--muted-foreground));
  line-height: 1.6em;
}

.DocSearch-Help,
.DocSearch-Label {
  color: hsl(var(--muted-foreground));
}

.DocSearch-Help {
  font-size: 0.9em;
  margin: 0;
  user-select: none;
}

.DocSearch-Title {
  font-size: 2em;
}

.DocSearch-Logo a {
  display: flex;
}

.DocSearch-Logo svg {
  margin-left: 12px;
}

.DocSearch-Hits:last-of-type {
  margin-bottom: 32px;
}

.DocSearch-Hits mark {
  background: none;
  color: var(--color-blue);
}

.DocSearch-HitsFooter {
  display: none;
}

.DocSearch-Hit {
  display: flex;
  position: relative;
  gap: 1rem;
  width: 100%;
  align-items: center;
  cursor: pointer;
  margin: 1px 0;
  border-radius: 0.75rem;
  border: 1px solid transparent;
}

.DocSearch-Hit:focus {
  background-color: hsl(var(--muted));
  border-color: hsl(var(--border));
}

.DocSearch-Hit *[data-enter-icon="true"] {
  display: none !important;
}

.DocSearch-Hit[aria-selected="true"] *[data-enter-icon="true"] {
  display: block !important;
}

.DocSearch-Hit[aria-selected="true"] {
  background-color: hsl(var(--muted));
  border-color: hsl(var(--border));
}

/* .DocSearch-Hit a {
  background: var(--background);
  border-radius: 0.75rem;
  display: block;
  padding-left: 10px;
  width: 100%;
} */

.DocSearch-Hit-source {
  background: var(--background);
  color: hsl(var(--muted-foreground));
  font-size: 0.85em;
  font-weight: 500;
  line-height: 32px;
  margin: 0;
  padding: 8px 4px 0;
  /* position: sticky; */
  top: 0;
  z-index: 10;
}

.DocSearch-Hit-Tree {
  color: var(--foreground);
  height: 32px;
  opacity: 0.5;
  stroke-width: var(--docsearch-icon-stroke-width);
  width: 24px;
}

.DocSearch-Hit[aria-selected="true"] a {
  background-color: hsl(var(--muted));
}

.DocSearch-Hit[aria-selected="true"] mark {
  color: var(--color-blue) !important;
  text-decoration: underline;
}

.DocSearch-Hit-Container {
  align-items: center;
  color: var(--docsearch-hit-color);
  display: flex;
  flex-direction: row;
  height: var(--docsearch-hit-height);
  padding: 0 12px 0 0;
}

.DocSearch-Hit-icon {
  height: 16px;
  width: 16px;
  align-items: center;
  display: flex;
}

/* .DocSearch-Hit-action,
.DocSearch-Hit-icon {
  color: var(--docsearch-muted-color);
  stroke-width: var(--docsearch-icon-stroke-width);
} */

.DocSearch-Hit-action {
  align-items: center;
  display: flex;
  height: 16px;
  width: 16px;
}

.DocSearch-Hit-action svg {
  display: block;
  height: 16px;
  width: 16px;
}

.DocSearch-Hit-action + .DocSearch-Hit-action {
  margin-left: 6px;
}

/* .DocSearch-Hit-action-button {
  color: var(--red);
  appearance: none;
  background: none;
  border: 0;
  border-radius: 50%;
  color: inherit;
  cursor: pointer;
  padding: 2px;
} */

svg.DocSearch-Hit-Select-Icon {
  display: none;
}

.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-Select-Icon {
  display: block;
}

/* .DocSearch-Hit-action-button:focus,
.DocSearch-Hit-action-button:hover {
  background: rgba(0, 0, 0, 0.2);
  transition: background-color 0.1s ease-in;
} */

@media screen and (prefers-reduced-motion: reduce) {
  .DocSearch-Hit-action-button:focus,
  .DocSearch-Hit-action-button:hover {
    transition: none;
  }
}

.DocSearch-Hit-action-button:focus path,
.DocSearch-Hit-action-button:hover path {
  fill: #fff;
}

.DocSearch-Hit-content-wrapper {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  font-weight: 500;
  justify-content: center;
  line-height: 1.2em;
  margin: 0 12px;
  overflow-x: hidden;
  position: relative;
  text-overflow: ellipsis;
  color: hsl(var(--muted-foreground));
  white-space: nowrap;
  width: 100%;
}

.DocSearch-Hit-title {
  font-size: 0.9em;
}

.DocSearch-Hit-path {
  color: hsl(var(--muted-foreground));
  font-size: 0.75em;
}

.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-Tree,
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-action,
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-icon,
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-path,
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-text,
.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-title,
.DocSearch-Hit[aria-selected="true"] mark {
  color: var(--foreground);
}

/* @media screen and (prefers-reduced-motion: reduce) {
  .DocSearch-Hit-action-button:focus,
  .DocSearch-Hit-action-button:hover {
    background: rgba(0, 0, 0, 0.2);
    transition: none;
  }
} */

.DocSearch-ErrorScreen,
.DocSearch-NoResults,
.DocSearch-StartScreen {
  /* display: none; */
  font-size: 0.5rem;
  margin: 0 auto;
  padding: 36px 0;
  text-align: center;
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 100%;
  width: 80%;
}

.DocSearch-Screen-Icon {
  display: none;
}

.DocSearch-NoResults-Prefill-List {
  display: none;
  padding-bottom: 24px;
  text-align: left;
}

.DocSearch-NoResults-Prefill-List ul {
  display: inline-block;
  padding: 8px 0 0;
}

.DocSearch-NoResults-Prefill-List li {
  list-style-position: inside;
  list-style-type: "» ";
}

.DocSearch-Prefill {
  appearance: none;
  background: none;
  border: 0;
  border-radius: 1em;
  color: var(--docsearch-highlight-color);
  cursor: pointer;
  display: inline-block;
  font-size: 1em;
  font-weight: 500;
  padding: 0;
}

.DocSearch-Prefill:focus,
.DocSearch-Prefill:hover {
  outline: none;
  text-decoration: underline;
}

.DocSearch-Footer {
  align-items: center;
  background: hsl(var(--background));
  border-radius: 0 0 0.75rem 0.75rem;
  display: flex;
  flex-direction: row-reverse;
  flex-shrink: 0;
  height: var(--docsearch-footer-height);
  justify-content: space-between;
  padding: 0 var(--docsearch-spacing);
  position: relative;
  user-select: none;
  width: 100%;
  z-index: 300;
}

.DocSearch-Commands {
  display: none;
}

.DocSearch-Commands-Key {
  display: none;
}

.DocSearch-VisuallyHiddenForAccessibility {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}

@media (max-width: 640px) {
  :root {
    --docsearch-spacing: 10px;
    --docsearch-footer-height: 40px;
  }

  .DocSearch-Dropdown {
    height: 100%;
    max-width: 100%;
  }

  .DocSearch-Container {
    height: 100vh;
    width: 100%;
    min-width: auto;
    height: -webkit-fill-available;
    height: calc(var(--docsearch-vh, 1vh) * 100);
    position: absolute;
  }

  .DocSearch-Footer {
    border-radius: 0;
    bottom: 0;
    position: absolute;
  }

  .DocSearch-Hit-content-wrapper {
    display: flex;
    position: relative;
    /* width: 80%; */
  }

  .DocSearch-Modal {
    border-radius: 0;
    box-shadow: none;
    /* height: 100vh; */
    height: -webkit-fill-available;
    height: calc(var(--docsearch-vh, 1vh) * 100);
    max-height: 100vh;
    margin: 0;
    max-width: 100%;
    min-width: auto;
    width: 100%;
  }

  .DocSearch-Dropdown {
    max-height: calc(
      var(--docsearch-vh, 1vh) * 100 - var(--docsearch-searchbox-height) -
        var(--docsearch-spacing) - var(--docsearch-footer-height)
    );
    max-width: 100%;
    min-width: auto;
  }
  .DocSearch-Cancel {
    appearance: none;
    background: none;
    /* display: none; */
    border: 0;
    cursor: pointer;
    display: flex;
    align-items: center;
    flex: none;
    font: inherit;
    font-size: 1em;
    margin-right: 1rem;
    font-weight: 500;
    outline: none;
    overflow: hidden;
    padding: 0;
    user-select: none;
    white-space: nowrap;
  }

  .DocSearch-Form {
    border: none;
  }

  .DocSearch-Commands,
  .DocSearch-Hit-Tree {
    display: none;
  }

  .DocSearch-SearchBar {
    border-bottom: 1px solid hsl(var(--border));
  }
}

@keyframes fade-in {
  0% {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* Button styling overrides */
/*! @docsearch/css Button 3.9.0 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */
.DocSearch-Button {
  align-items: center;
  background: hsl(var(--muted));
  border: 1px solid hsl(var(--border));
  border-radius: 0.5rem;
  color: hsl(var(--muted-foreground));
  cursor: pointer;
  display: flex;
  font-weight: 400;
  height: 36px;
  width: 256px;
  justify-content: space-between;
  margin: 0 0 0 16px;
  padding: 2px 8px;
  user-select: none;
  outline: 2px solid transparent;
  outline-offset: 2px;
  transition: all 200ms ease-out;
}

.DocSearch-Button:active,
.DocSearch-Button:hover {
  background: hsl(var(--muted));
  box-shadow: none;
  opacity: 0.75;
  transition: all 300ms ease-out;
}

.DocSearch-Button:focus,
.DocSearch-Button:focus-visible {
  background: hsl(var(--muted));
  box-shadow: none;
  opacity: 0.75;
  outline: 2px solid var(--color-primary-blue);
  outline-offset: 2px;
}

.DocSearch-Button-Container {
  align-items: center;
  display: flex;
}

.DocSearch-Search-Icon {
  stroke-width: 1.5;
}

.DocSearch-Button .DocSearch-Search-Icon {
  color: hsl(var(--muted-foreground));
  width: 15px;
  height: 15px;
}

.DocSearch-Button-Placeholder {
  font-size: 1rem;
  padding: 0 12px 0 8px;
}

.DocSearch-Button-Keys {
  display: flex;
  border-radius: 0.275rem;
  /* border: 1px solid hsl(var(--border)); */
  background: hsl(var(--background));
  font-size: 1rem;
  padding: 0 3px 0 3px;
}

.DocSearch-Button-Key {
  align-items: center;
  background: transparent;
  box-shadow: none;
  color: hsl(var(--muted-foreground));
  display: flex;
  height: 20px;
  justify-content: center;
  align-items: center;
  padding: 0px;
  width: 14px;
}

.DocSearch-Button-Key:nth-child(2) {
  font-size: 0.7rem;
}

@media (prefers-reduced-motion) {
  .DocSearch-Button-Key {
    transition: none;
  }
}

.DocSearch-Button-Key--pressed {
  color: hsl(var(--primary));
  font-weight: 800;
  
}

@media (max-width: 1024px) {
  .DocSearch-Button {
    background: transparent;
    background-color: transparent !important;
    stroke: hsl(var(--foreground)) !important;
    color: hsl(var(--foreground)) !important;
    border: none;
    width: 36px;
    height: 36px;
    margin: 0;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .DocSearch-Button .DocSearch-Search-Icon {
    color: hsl(var(--foreground)) !important;
    width: 18px;
    height: 18px;
    display: block !important;
    stroke-width: 2;
    stroke: hsl(var(--foreground)) !important;
    transition: stroke-width 300ms cubic-bezier(0.4, 0, 0.2, 1);
  }

  .DocSearch-Button:hover .DocSearch-Search-Icon {
    stroke-width: 3;
    background: transparent !important;
    background-color: transparent !important;
  }

  .DocSearch-Button-Placeholder,
  .DocSearch-Button-Keys {
    display: none;
  }
}

@media (min-width: 1025px) {
  .DocSearch-Button .DocSearch-Search-Icon {
    display: none;
  }
}

/* Mobile overrides */
@media (max-width: 640px) {
  :root {
    --docsearch-spacing: 10px; /* Default: 10px - Reduced spacing for mobile */
    --docsearch-footer-height: 40px; /* Default: 40px - Reduced footer height for mobile */
  }
}
src/styles/modal.css
파일 저장

/*! @docsearch/css Modal 3.9.0 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */

src/types/nav.ts
파일 저장

import { Icons } from "@/components/icons"

export interface NavItem {
  title: string
  href?: string
  disabled?: boolean
  external?: boolean
  icon?: keyof typeof Icons
  label?: string
}

export interface NavItemWithChildren extends NavItem {
  items: NavItemWithChildren[]
}

export interface MainNavItem extends NavItem {}

export interface SidebarNavItem extends NavItemWithChildren {}
src/types/types.ts
파일 저장

export type Doc = {
  _id: string
  type: "Doc"
  title: string
  description: string
  published: boolean
  featured: boolean
  component: boolean
  toc: any
  author: string
  /** MDX file body */
  body: any
  slug: string
  slugAsParams: string
}

export type NpmCommands = {
  __npmCommand__?: string
  __yarnCommand__?: string
  __pnpmCommand__?: string
  __bunCommand__?: string
}

export interface DocPageProps {
  params: Promise<{
    slug: string[]
  }>
}

// CMS data
export type Component = {
  slug: string
  name: string
  thumbnail: {
    url: string
  }
  demo: {
    url: string
  }
  category: string
}
src/utils/calculate-position.ts
파일 저장

export function calculatePosition(
  value: number | string | undefined,
  containerSize: number,
  elementSize: number
): number {
  // Handle percentage strings (e.g. "50%")
  if (typeof value === "string" && value.endsWith("%")) {
    const percentage = parseFloat(value) / 100
    return containerSize * percentage
  }

  // Handle direct pixel values
  if (typeof value === "number") {
    return value
  }

  // If no value provided, center the element
  return (containerSize - elementSize) / 2
}
src/utils/demo-images.ts
파일 저장

export const exampleImages = [
  {
    url: "https://images.unsplash.com/photo-1727341554370-80e0fe9ad082?q=80&w=2276&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    author: "Branislav Rodman",
    link: "https://unsplash.com/photos/a-black-and-white-photo-of-a-woman-brushing-her-teeth-r1SjnJL5tf0",
    title: "A Black and White Photo of a Woman Brushing Her Teeth",
  },
  {
    url: "https://images.unsplash.com/photo-1640680608781-2e4199dd1579?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    link: "https://unsplash.com/photos/a-painting-of-a-palm-leaf-on-a-multicolored-background-AaNPwrSNOFE",
    title: "Neon Palm",
    author: "Tim Mossholder",
  },
  {
    url: "https://images.unsplash.com/photo-1726083085160-feeb4e1e5b00?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    link: "https://unsplash.com/photos/a-blurry-photo-of-a-crowd-of-people-UgbxzloNGsc",
    author: "ANDRII SOLOK",
    title: "A blurry photo of a crowd of people",
  },
  {
    url: "https://images.unsplash.com/photo-1562016600-ece13e8ba570?q=80&w=2838&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    link: "https://unsplash.com/photos/rippling-crystal-blue-water-9-OCsKoyQlk",
    author: "Wesley Tingey",
    title: "Rippling Crystal Blue Water",
  },
  {
    url: "https://images.unsplash.com/photo-1624344965199-ed40391d20f2?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    link: "https://unsplash.com/de/fotos/mann-im-schwarzen-hemd-unter-blauem-himmel-m8RDNiuEXro",
    author: "Serhii Tyaglovsky",
    title: "Mann im schwarzen Hemd unter blauem Himmel",
  },
  {
    url: "https://images.unsplash.com/photo-1689553079282-45df1b35741b?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    link: "https://unsplash.com/photos/a-woman-with-a-flower-crown-on-her-head-0S3muIttbsY",
    author: "Vladimir Yelizarov",
    title: "A women with a flower crown on her head",
  },
  {
    url: "https://images.unsplash.com/photo-1721968317938-cf8c60fccd1a?q=80&w=2728&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    title: "A blurry photo of white flowers in a field",
    author: "Eugene Golovesov",
    link: "https://unsplash.com/photos/a-blurry-photo-of-white-flowers-in-a-field-6qbx0lzGPyc",
  },
  {
    url: "https://images.unsplash.com/photo-1677338354108-223e807fb1bd?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    author: "Mathilde Langevin",
    link: "https://unsplash.com/photos/a-table-topped-with-two-wine-glasses-and-plates-Ig0gRAHspV0",
    title: "A table topped with two wine glasses and plates",
  },
]
src/utils/svg-path-to-vertices.ts
파일 저장

import SVGPathCommander from "svg-path-commander"

// Function to convert SVG path `d` to vertices
export function parsePathToVertices(path: string, sampleLength = 15) {
  // Convert path to absolute commands
  const commander = new SVGPathCommander(path)

  const points: { x: number; y: number }[] = []
  let lastPoint: { x: number; y: number } | null = null

  // Get total length of the path
  const totalLength = commander.getTotalLength()
  let length = 0

  // Sample points along the path
  while (length < totalLength) {
    const point = commander.getPointAtLength(length)

    // Only add point if it's different from the last one
    if (!lastPoint || point.x !== lastPoint.x || point.y !== lastPoint.y) {
      points.push({ x: point.x, y: point.y })
      lastPoint = point
    }

    length += sampleLength
  }

  // Ensure we get the last point
  const finalPoint = commander.getPointAtLength(totalLength)
  if (
    lastPoint &&
    (finalPoint.x !== lastPoint.x || finalPoint.y !== lastPoint.y)
  ) {
    points.push({ x: finalPoint.x, y: finalPoint.y })
  }

  return points
}
Media credits and license evidence실행 안내·자료
파일 저장

README.md
## Acknowledgments

Huge thanks to [shadcn](https://github.com/shadcn-ui/ui), as many parts of this repository—documentation page, structure, registry system, guides, and many more—is built upon it.


## License

Licensed under the [MIT license](LICENSE).

<br/>
<a href="https://vercel.com/oss">
    <img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
LICENSE실행 안내·자료
파일 저장

MIT License

Copyright (c) 2024 Daniel Petho

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bundled dependency licenses실행 안내·자료
파일 저장

react@19.2.1 — LICENSE
MIT License

Copyright (c) Meta Platforms, Inc. and affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


scheduler@0.27.0 — LICENSE
MIT License

Copyright (c) Meta Platforms, Inc. and affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


react-dom@19.2.1 — LICENSE
MIT License

Copyright (c) Meta Platforms, Inc. and affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


framer-motion@12.23.24 — LICENSE.md
The MIT License (MIT)

Copyright (c) 2018 Framer B.V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


motion-utils@12.23.6 — LICENSE.md
The MIT License (MIT)

Copyright (c) 2024 [Motion](https://motion.dev) B.V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


motion-dom@12.23.23 — LICENSE.md
The MIT License (MIT)

Copyright (c) 2024 [Motion](https://motion.dev) B.V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


motion@12.23.24 — LICENSE.md
The MIT License (MIT)

Copyright (c) 2024 [Motion](https://motion.dev) B.V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


clsx@2.1.1 — license
MIT License

Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


tailwind-merge@2.3.0 — LICENSE.md
MIT License

Copyright (c) 2021 Dany Castillo

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.