{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"skeleton","type":"registry:component","title":"Skeleton","description":"Loading placeholders: Skeleton blocks in rect, circle or text shapes with a gradient shimmer, SkeletonText paragraphs with a natural short last line, and SkeletonSwap, which cross-fades from skeleton to real content in one grid cell and holds the last measured height on reload so nothing jumps; reduced motion swaps the shimmer for an opacity pulse.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/skeleton.tsx","type":"registry:component","target":"@components/motion/skeleton.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/skeleton\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SkeletonShape = \"rect\" | \"circle\" | \"text\";\n\nexport interface SkeletonProps {\n  shape?: SkeletonShape;\n  /** CSS width (number = px). Text defaults to 100%, circles to 40px. */\n  width?: number | string;\n  /** CSS height (number = px). Circles default to their width. */\n  height?: number | string;\n  /** Sweep a highlight across the block. Reduced motion swaps it for a pulse. */\n  shimmer?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst SHAPE_CLASS: Record<SkeletonShape, string> = {\n  rect: \"rounded-lg\",\n  circle: \"rounded-full shrink-0\",\n  text: \"h-3.5 rounded-md\",\n};\n\n// Reduced motion: no sweep; the block breathes in opacity only.\nconst PULSE = {\n  animate: { opacity: [1, 0.55, 1] },\n  transition: { duration: 1.6, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\n/**\n * A placeholder block. The shimmer is a gradient strip translated across the\n * block (transform only), clipped by the block's own overflow.\n */\nexport function Skeleton({\n  shape = \"rect\",\n  width,\n  height,\n  shimmer = true,\n  className,\n  style,\n}: SkeletonProps) {\n  const reduce = useReducedMotion();\n  const resolvedWidth = width ?? (shape === \"circle\" ? 40 : undefined);\n  const resolvedHeight = height ?? (shape === \"circle\" ? resolvedWidth : undefined);\n\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative overflow-hidden bg-muted\",\n        SHAPE_CLASS[shape],\n        shape === \"text\" && \"w-full\",\n        className,\n      )}\n      style={{ width: resolvedWidth, height: resolvedHeight, ...style }}\n      animate={reduce && shimmer ? PULSE.animate : undefined}\n      transition={reduce && shimmer ? PULSE.transition : undefined}\n    >\n      {shimmer && !reduce ? (\n        <motion.span\n          className=\"absolute inset-y-0 left-0 w-full bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--foreground)_9%,transparent),transparent)]\"\n          initial={{ x: \"-100%\" }}\n          animate={{ x: \"100%\" }}\n          transition={{\n            duration: 1.5,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            repeatDelay: 0.3,\n          }}\n        />\n      ) : null}\n    </motion.div>\n  );\n}\n\nexport interface SkeletonTextProps {\n  /** Number of lines. */\n  lines?: number;\n  /** Width of the final line, so the block reads like a real paragraph. */\n  lastLineWidth?: number | string;\n  /** Gap between lines (Tailwind class). */\n  gapClassName?: string;\n  lineClassName?: string;\n  shimmer?: boolean;\n  className?: string;\n}\n\n/** A paragraph of text lines with a shorter, natural-looking last line. */\nexport function SkeletonText({\n  lines = 3,\n  lastLineWidth = \"62%\",\n  gapClassName = \"gap-2\",\n  lineClassName,\n  shimmer = true,\n  className,\n}: SkeletonTextProps) {\n  const count = Math.max(1, lines);\n  return (\n    <div aria-hidden=\"true\" className={cn(\"flex w-full flex-col\", gapClassName, className)}>\n      {Array.from({ length: count }, (_, i) => (\n        <Skeleton\n          // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional and never reorder\n          key={i}\n          shape=\"text\"\n          shimmer={shimmer}\n          className={lineClassName}\n          width={count > 1 && i === count - 1 ? lastLineWidth : \"100%\"}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport interface SkeletonSwapProps {\n  /** While true the skeleton shows; flip to false to cross-fade to children. */\n  loading: boolean;\n  /** Placeholder rendered while loading — mirror the real content's shape. */\n  skeleton: ReactNode;\n  /** Real content. May be absent while loading. */\n  children?: ReactNode;\n  /**\n   * Keep the last measured content height while reloading, so a reload never\n   * collapses the box to the skeleton's height. Default true.\n   */\n  keepHeight?: boolean;\n  /** Announced to screen readers while loading. */\n  loadingLabel?: string;\n  className?: string;\n}\n\n/**\n * Cross-fades from a skeleton to real content without layout shift: both\n * layers share one grid cell during the fade (the box takes the taller of the\n * two), and the last measured content height is held as a min-height while a\n * reload shows the skeleton again.\n */\nexport function SkeletonSwap({\n  loading,\n  skeleton,\n  children,\n  keepHeight = true,\n  loadingLabel = \"Loading\",\n  className,\n}: SkeletonSwapProps) {\n  const reduce = useReducedMotion();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [measured, setMeasured] = useState<number | null>(null);\n\n  useEffect(() => {\n    const node = contentRef.current;\n    if (loading || !node || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(([entry]) => {\n      if (entry) setMeasured(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    ro.observe(node);\n    return () => ro.disconnect();\n  }, [loading]);\n\n  return (\n    <div\n      aria-busy={loading}\n      className={cn(\"relative grid\", className)}\n      style={keepHeight && loading && measured != null ? { minHeight: measured } : undefined}\n    >\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {loading ? loadingLabel : \"\"}\n      </span>\n      <AnimatePresence initial={false}>\n        {loading ? (\n          <motion.div\n            key=\"skeleton\"\n            className=\"[grid-area:1/1]\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.18, ease: EASE_OUT } }}\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n          >\n            {skeleton}\n          </motion.div>\n        ) : (\n          <motion.div\n            key=\"content\"\n            ref={contentRef}\n            className=\"[grid-area:1/1]\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}\n            transition={{ duration: 0.28, ease: EASE_OUT }}\n          >\n            {children}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\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"}]}