{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"content-section","type":"registry:block","title":"Content Section","description":"Long-form marketing content block in three variants: split copy beside media that stays pinned while paragraphs scroll, a stats layout whose callouts roll their numbers up on view, and a pull quote framed by large typographic quote marks, with paragraphs revealed as they scroll into view.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/content-section.tsx","type":"registry:component","target":"@components/motion/content-section.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/content-section\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useMemo } from \"react\";\nimport { AnimatedNumber } from \"@/components/motion/animated-number\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ContentStat {\n  value: number;\n  label: ReactNode;\n  /** Short line under the label. */\n  description?: ReactNode;\n  prefix?: string;\n  suffix?: string;\n  /** Fixed decimal places while rolling and at rest. Default 0. */\n  decimals?: number;\n}\n\nexport interface ContentQuoteAuthor {\n  name: string;\n  role?: ReactNode;\n  /** Avatar node (image, illustration). Falls back to initials. */\n  avatar?: ReactNode;\n}\n\nexport interface ContentSectionProps {\n  /**\n   * \"split\": copy beside media (media sticks while the copy scrolls on wide\n   * containers). \"stats\": copy plus rolling stat callouts. \"quote\": a pull\n   * quote with large quote marks and its author.\n   */\n  variant?: \"split\" | \"stats\" | \"quote\";\n  eyebrow?: ReactNode;\n  heading?: ReactNode;\n  /** Body copy; each entry is revealed as it scrolls into view. */\n  paragraphs?: ReactNode[];\n  /** Extra content after the paragraphs (CTA, list). */\n  children?: ReactNode;\n  /** `variant=\"split\"`: media node (image, mock UI, illustration). */\n  media?: ReactNode;\n  mediaPosition?: \"left\" | \"right\";\n  /** `variant=\"split\"`: keep the media pinned while the copy scrolls. Default true. */\n  stickyMedia?: boolean;\n  /** `variant=\"stats\"`: up to 3 reads best. */\n  stats?: ContentStat[];\n  /** `variant=\"quote\"`: the quote itself, without quote marks. */\n  quote?: ReactNode;\n  author?: ContentQuoteAuthor;\n  /** Number formatting locale. Default \"en-US\" so server and client agree. */\n  locale?: string;\n  className?: string;\n}\n\nconst REVEAL_DURATION = 0.55;\n\nfunction Reveal({\n  children,\n  delay = 0,\n  className,\n}: {\n  children: ReactNode;\n  delay?: number;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: reduce ? 0 : 18 }}\n      whileInView={{ opacity: 1, y: 0 }}\n      viewport={{ once: true, amount: 0.4 }}\n      transition={{ duration: REVEAL_DURATION, ease: EASE_OUT, delay }}\n      className={className}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction Header({\n  eyebrow,\n  heading,\n  center = false,\n}: {\n  eyebrow?: ReactNode;\n  heading?: ReactNode;\n  center?: boolean;\n}) {\n  if (!eyebrow && !heading) return null;\n  return (\n    <Reveal className={cn(\"flex flex-col gap-3\", center && \"items-center text-center\")}>\n      {eyebrow ? (\n        <p className=\"text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground\">\n          {eyebrow}\n        </p>\n      ) : null}\n      {heading ? (\n        <h2 className=\"text-balance text-2xl font-semibold leading-tight tracking-tight @3xl:text-3xl\">\n          {heading}\n        </h2>\n      ) : null}\n    </Reveal>\n  );\n}\n\nfunction Paragraphs({ paragraphs }: { paragraphs?: ReactNode[] }) {\n  if (!paragraphs?.length) return null;\n  return (\n    <div className=\"flex flex-col gap-4\">\n      {paragraphs.map((paragraph, index) => (\n        // biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are positional copy.\n        <Reveal key={index}>\n          <p className=\"text-pretty text-[15px] leading-7 text-muted-foreground\">{paragraph}</p>\n        </Reveal>\n      ))}\n    </div>\n  );\n}\n\nfunction StatValue({ stat, locale }: { stat: ContentStat; locale: string }) {\n  const decimals = stat.decimals ?? 0;\n  const formatter = useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        minimumFractionDigits: decimals,\n        maximumFractionDigits: decimals,\n      }),\n    [locale, decimals],\n  );\n  return (\n    <span className=\"text-3xl font-semibold tracking-tight text-foreground tabular-nums @3xl:text-4xl\">\n      {stat.prefix}\n      <AnimatedNumber value={stat.value} format={(n) => formatter.format(n)} />\n      {stat.suffix}\n    </span>\n  );\n}\n\nfunction initials(name: string) {\n  const parts = name.trim().split(/\\s+/);\n  const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? \"\") : \"\";\n  return ((parts[0]?.[0] ?? \"\") + last).toUpperCase();\n}\n\n/**\n * Long-form marketing content block in three variants. \"split\" sets copy\n * beside a media slot that stays pinned while the copy scrolls (on wide\n * containers); \"stats\" pairs copy with stat callouts whose numbers roll up\n * when they scroll into view; \"quote\" is a pull quote framed by large\n * typographic quote marks with its author. Paragraphs reveal on scroll.\n */\nexport function ContentSection({\n  variant = \"split\",\n  eyebrow,\n  heading,\n  paragraphs,\n  children,\n  media,\n  mediaPosition = \"right\",\n  stickyMedia = true,\n  stats = [],\n  quote,\n  author,\n  locale = \"en-US\",\n  className,\n}: ContentSectionProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  if (variant === \"quote\") {\n    return (\n      <section className={cn(\"@container w-full text-foreground\", className)}>\n        <figure className=\"relative mx-auto m-0 flex max-w-3xl flex-col items-center gap-6 px-2 py-6 text-center\">\n          {eyebrow ? (\n            <Reveal>\n              <p className=\"text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground\">\n                {eyebrow}\n              </p>\n            </Reveal>\n          ) : null}\n          <div className=\"relative px-6 @md:px-12\">\n            {/* Decorative marks: a transform pop on view, nothing else moves. */}\n            <motion.span\n              aria-hidden\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n              whileInView={{ opacity: 1, scale: 1 }}\n              viewport={{ once: true, amount: 0.6 }}\n              transition={reduce ? { duration: 0.3, ease: EASE_OUT } : SPRING_SWAP}\n              className=\"pointer-events-none absolute -top-6 left-0 select-none font-serif text-7xl leading-none text-foreground/15 @md:-top-8 @md:text-8xl\"\n            >\n              &ldquo;\n            </motion.span>\n            <motion.span\n              aria-hidden\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n              whileInView={{ opacity: 1, scale: 1 }}\n              viewport={{ once: true, amount: 0.6 }}\n              transition={\n                reduce ? { duration: 0.3, ease: EASE_OUT } : { ...SPRING_SWAP, delay: 0.12 }\n              }\n              className=\"pointer-events-none absolute right-0 -bottom-14 select-none font-serif text-7xl leading-none text-foreground/15 @md:-bottom-20 @md:text-8xl\"\n            >\n              &rdquo;\n            </motion.span>\n            <Reveal delay={0.05}>\n              <blockquote className=\"m-0\">\n                <p className=\"text-balance text-xl font-medium leading-snug tracking-tight @md:text-2xl @3xl:text-3xl\">\n                  {quote}\n                </p>\n              </blockquote>\n            </Reveal>\n          </div>\n          {author ? (\n            <Reveal delay={0.15}>\n              <figcaption className=\"flex items-center gap-3 text-left\">\n                <span className=\"grid size-10 shrink-0 place-items-center overflow-hidden rounded-full border border-border bg-muted text-xs font-semibold text-foreground\">\n                  {author.avatar ?? initials(author.name)}\n                </span>\n                <span className=\"flex flex-col\">\n                  <span className=\"text-sm font-medium text-foreground\">{author.name}</span>\n                  {author.role ? (\n                    <span className=\"text-xs text-muted-foreground\">{author.role}</span>\n                  ) : null}\n                </span>\n              </figcaption>\n            </Reveal>\n          ) : null}\n          {children}\n        </figure>\n      </section>\n    );\n  }\n\n  if (variant === \"stats\") {\n    return (\n      <section className={cn(\"@container w-full text-foreground\", className)}>\n        <div className=\"flex flex-col gap-8\">\n          <div className=\"grid gap-6 @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @3xl:gap-10\">\n            <Header eyebrow={eyebrow} heading={heading} />\n            <div className=\"flex flex-col gap-4\">\n              <Paragraphs paragraphs={paragraphs} />\n              {children}\n            </div>\n          </div>\n          {stats.length > 0 ? (\n            <dl className=\"m-0 grid grid-cols-1 overflow-hidden rounded-2xl border border-border bg-card @lg:grid-cols-3\">\n              {stats.map((stat, index) => (\n                <Reveal\n                  // biome-ignore lint/suspicious/noArrayIndexKey: stats are positional.\n                  key={index}\n                  delay={index * 0.08}\n                  className={cn(\n                    \"flex flex-col gap-1 p-5\",\n                    index > 0 && \"border-t border-border @lg:border-t-0 @lg:border-l\",\n                  )}\n                >\n                  <dt className=\"order-2 text-sm font-medium text-foreground\">{stat.label}</dt>\n                  <dd className=\"order-1 m-0\">\n                    <StatValue stat={stat} locale={locale} />\n                  </dd>\n                  {stat.description ? (\n                    <dd className=\"order-3 m-0 text-xs leading-5 text-muted-foreground\">\n                      {stat.description}\n                    </dd>\n                  ) : null}\n                </Reveal>\n              ))}\n            </dl>\n          ) : null}\n        </div>\n      </section>\n    );\n  }\n\n  return (\n    <section className={cn(\"@container w-full text-foreground\", className)}>\n      <div className=\"grid gap-8 @xl:grid-cols-2 @xl:gap-10 @4xl:gap-14\">\n        <div className=\"flex flex-col gap-5\">\n          <Header eyebrow={eyebrow} heading={heading} />\n          <Paragraphs paragraphs={paragraphs} />\n          {children}\n        </div>\n        {media ? (\n          <div\n            className={cn(\n              \"@xl:self-start\",\n              stickyMedia && \"@xl:sticky @xl:top-6\",\n              mediaPosition === \"left\" && \"@xl:order-first\",\n            )}\n          >\n            <Reveal>{media}</Reveal>\n          </div>\n        ) : null}\n      </div>\n    </section>\n  );\n}\n"},{"path":"components/motion/animated-number.tsx","type":"registry:component","target":"@components/motion/animated-number.tsx","content":"\"use client\";\n\nimport { animate, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AnimatedNumberProps {\n  value: number;\n  duration?: number;\n  format?: (n: number) => string;\n  className?: string;\n  startOnView?: boolean;\n}\n\nexport function AnimatedNumber({\n  value,\n  duration = 1.2,\n  format = (n) => Math.round(n).toLocaleString(\"en-US\"),\n  className,\n  startOnView = true,\n}: AnimatedNumberProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const inView = useInView(ref, { once: true, amount: 0.6 });\n  const reduce = useReducedMotion();\n  const [display, setDisplay] = useState(0);\n  const fromRef = useRef(0);\n\n  useEffect(() => {\n    if (startOnView && !inView) return;\n    if (reduce) {\n      fromRef.current = value;\n      setDisplay(value);\n      return;\n    }\n    const controls = animate(fromRef.current, value, {\n      duration,\n      ease: EASE_OUT,\n      onUpdate: (v) => setDisplay(v),\n    });\n    fromRef.current = value;\n    return () => controls.stop();\n  }, [value, duration, inView, startOnView, reduce]);\n\n  return (\n    <span ref={ref} className={cn(\"tabular-nums\", className)}>\n      {format(display)}\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}