{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"skeleton-loader","type":"registry:ui","title":"Skeleton Loader","description":"Skeleton shapes you compose freely, all lit by one shimmer, that cross-fade into the real content when it loads.","dependencies":[],"registryDependencies":["https://ui.mekaigames.com/r/theme.json"],"files":[{"path":"components/ui/skeleton-loader.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\n\ntype Shimmer = {\n  root: RefObject<HTMLElement | null>;\n  /** Seconds for one sweep. */\n  duration: number;\n  base: string;\n  highlight: string;\n  angle: number;\n};\n\nconst DEFAULT_DURATION = 1.6;\nconst DEFAULT_BASE = \"#211d1a\";\nconst DEFAULT_HIGHLIGHT = \"#4a3b32\";\nconst DEFAULT_ANGLE = 105;\n/** The gradient image is never smaller than this, so its soft edges stay out of small shapes. */\nconst MIN_IMAGE = 520;\n\nconst ShimmerContext = createContext<Shimmer | null>(null);\n\n/**\n * Runs the sweep on one shape. Every shape paints a slice of the same gradient, aligned to the group's\n * top left corner, and every animation starts at time zero of the document clock. The result reads as a\n * single band of light passing over all of them, even for shapes that mount later.\n */\nfunction useSweep(ref: RefObject<HTMLElement | null>) {\n  const shimmer = useContext(ShimmerContext);\n  const root = shimmer?.root;\n  const duration = shimmer?.duration ?? DEFAULT_DURATION;\n  const highlight = shimmer?.highlight ?? DEFAULT_HIGHLIGHT;\n  const angle = shimmer?.angle ?? DEFAULT_ANGLE;\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof el.animate !== \"function\") return;\n    const shape = el;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let sweep: Animation | null = null;\n    let visible = true;\n    let placed = \"\";\n\n    function stop() {\n      sweep?.cancel();\n      sweep = null;\n      placed = \"\";\n      shape.style.backgroundImage = \"none\";\n    }\n\n    function start() {\n      if (!visible || reduced.matches) {\n        stop();\n        return;\n      }\n      const box = (root?.current ?? shape).getBoundingClientRect();\n      const own = shape.getBoundingClientRect();\n      if (own.width === 0 || own.height === 0) {\n        stop();\n        return;\n      }\n      const left = own.left - box.left;\n      const top = own.top - box.top;\n      const key = `${box.width}|${box.height}|${left}|${top}`;\n      if (sweep && key === placed) return;\n      sweep?.cancel();\n      placed = key;\n\n      const width = Math.max(box.width, MIN_IMAGE);\n      const height = Math.max(box.height, MIN_IMAGE);\n      const band = Math.min(width * 0.3, 240);\n      shape.style.backgroundImage = `linear-gradient(${angle}deg, transparent calc(50% - ${band}px), ${highlight} 50%, transparent calc(50% + ${band}px))`;\n      shape.style.backgroundSize = `${width}px ${height}px`;\n      sweep = shape.animate(\n        [{ backgroundPosition: `${-width - left}px ${-top}px` }, { backgroundPosition: `${box.width - left}px ${-top}px` }],\n        { duration: duration * 1000, iterations: Infinity, easing: \"linear\" },\n      );\n      sweep.startTime = 0;\n    }\n\n    const resize = new ResizeObserver(start);\n    resize.observe(shape);\n    if (root?.current) resize.observe(root.current);\n    const view = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      start();\n    });\n    view.observe(shape);\n    reduced.addEventListener(\"change\", start);\n\n    return () => {\n      resize.disconnect();\n      view.disconnect();\n      reduced.removeEventListener(\"change\", start);\n      sweep?.cancel();\n    };\n  }, [ref, root, duration, highlight, angle]);\n}\n\nexport type SkeletonShape = \"rect\" | \"text\" | \"circle\" | \"pill\";\n\nexport type SkeletonProps = {\n  /** Sets the default size and corners. */\n  shape?: SkeletonShape;\n  width?: number | string;\n  height?: number | string;\n  /** Width and height together, handy for circles. */\n  size?: number | string;\n  /** Corner radius. */\n  radius?: number | string;\n  className?: string;\n  style?: CSSProperties;\n};\n\nconst SHAPES: Record<SkeletonShape, { width: number | string; height: number | string; borderRadius: number | string }> = {\n  rect: { width: \"100%\", height: 96, borderRadius: 14 },\n  text: { width: \"100%\", height: 12, borderRadius: 6 },\n  circle: { width: 40, height: 40, borderRadius: \"50%\" },\n  pill: { width: 96, height: 28, borderRadius: 999 },\n};\n\n/** One placeholder shape. Inside a SkeletonLoader it shares the shimmer with every other shape. */\nexport function Skeleton({ shape = \"rect\", width, height, size, radius, className = \"\", style }: SkeletonProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const shimmer = useContext(ShimmerContext);\n  useSweep(ref);\n  const preset = SHAPES[shape];\n  return (\n    <span\n      ref={ref}\n      aria-hidden=\"true\"\n      className={`block shrink-0 ${className}`}\n      style={{\n        width: width ?? size ?? preset.width,\n        height: height ?? size ?? preset.height,\n        borderRadius: radius ?? preset.borderRadius,\n        backgroundColor: shimmer?.base ?? DEFAULT_BASE,\n        backgroundRepeat: \"no-repeat\",\n        ...style,\n      }}\n    />\n  );\n}\n\nexport type SkeletonTextProps = {\n  lines?: number;\n  /** Width of the last line, so the block reads as a paragraph. */\n  lastLineWidth?: number | string;\n  /** Height of each line in pixels. */\n  lineHeight?: number;\n  /** Space between lines in pixels. */\n  gap?: number;\n  className?: string;\n};\n\nconst LINE_WIDTHS = [\"100%\", \"94%\", \"98%\", \"91%\"];\n\n/** A paragraph of text lines with slightly uneven widths. */\nexport function SkeletonText({ lines = 3, lastLineWidth = \"62%\", lineHeight = 12, gap = 10, className = \"\" }: SkeletonTextProps) {\n  return (\n    <span className={`flex w-full flex-col ${className}`} style={{ gap }}>\n      {Array.from({ length: lines }, (_, i) => (\n        <Skeleton\n          key={i}\n          shape=\"text\"\n          height={lineHeight}\n          width={i === lines - 1 && lines > 1 ? lastLineWidth : LINE_WIDTHS[i % LINE_WIDTHS.length]}\n        />\n      ))}\n    </span>\n  );\n}\n\nexport type SkeletonLoaderProps = {\n  /** True shows the skeleton, false cross-fades into the children. */\n  loading: boolean;\n  /** Skeleton shapes laid out like the content that replaces them. */\n  fallback: ReactNode;\n  /** The real content. Rendered only once loading is false. */\n  children?: ReactNode;\n  /** Seconds for one sweep of the shimmer. */\n  duration?: number;\n  /** Fill of the shapes. */\n  baseColor?: string;\n  /** Color of the band of light. */\n  highlightColor?: string;\n  /** Direction of the band, in degrees. */\n  angle?: number;\n  /** Seconds for the cross-fade into the content. */\n  fade?: number;\n  /** Announced to screen readers while loading. */\n  label?: string;\n  className?: string;\n};\n\nconst EASE_OUT = \"cubic-bezier(0.22, 1, 0.36, 1)\";\n\n/** Holds a group of skeleton shapes on one shimmer and cross-fades them into the real content when it arrives. */\nexport function SkeletonLoader({\n  loading,\n  fallback,\n  children,\n  duration = DEFAULT_DURATION,\n  baseColor = DEFAULT_BASE,\n  highlightColor = DEFAULT_HIGHLIGHT,\n  angle = DEFAULT_ANGLE,\n  fade = 0.45,\n  label = \"Loading\",\n  className = \"\",\n}: SkeletonLoaderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const skeletonRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const mounted = useRef(false);\n\n  // The skeleton stays on top for the length of the fade after loading ends.\n  const [leaving, setLeaving] = useState(false);\n  const [wasLoading, setWasLoading] = useState(loading);\n  if (wasLoading !== loading) {\n    setWasLoading(loading);\n    setLeaving(!loading);\n  }\n\n  const shimmer = useMemo<Shimmer>(\n    () => ({ root: rootRef, duration, base: baseColor, highlight: highlightColor, angle }),\n    [duration, baseColor, highlightColor, angle],\n  );\n\n  // Cross-fade, and ease the height from the skeleton's to the content's so nothing below jumps.\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const skeleton = skeletonRef.current;\n    const content = contentRef.current;\n    if (!leaving || !root || !skeleton || !content) return;\n    const ms = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? 0 : fade * 1000;\n    const from = skeleton.offsetHeight;\n    const to = content.offsetHeight;\n    const running = [\n      skeleton.animate([{ opacity: 1 }, { opacity: 0 }], { duration: ms * 0.8, easing: \"ease-out\", fill: \"forwards\" }),\n      content.animate(\n        [\n          { opacity: 0, filter: \"blur(6px)\", transform: \"translateY(6px)\" },\n          { opacity: 1, filter: \"blur(0)\", transform: \"none\" },\n        ],\n        { duration: ms, easing: EASE_OUT },\n      ),\n    ];\n    if (from !== to) {\n      root.style.overflow = \"hidden\";\n      running.push(root.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: ms, easing: EASE_OUT }));\n    }\n    let active = true;\n    Promise.all(running.map((a) => a.finished)).then(\n      () => {\n        if (active) setLeaving(false);\n      },\n      () => {},\n    );\n    return () => {\n      active = false;\n      running.forEach((a) => a.cancel());\n      root.style.overflow = \"\";\n    };\n  }, [leaving, fade]);\n\n  // Loading again: the skeleton fades back in.\n  useLayoutEffect(() => {\n    if (!mounted.current) {\n      mounted.current = true;\n      return;\n    }\n    if (loading && !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      skeletonRef.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fade * 500, easing: \"ease-out\" });\n    }\n  }, [loading, fade]);\n\n  return (\n    <div ref={rootRef} aria-busy={loading} className={`relative ${className}`}>\n      <ShimmerContext.Provider value={shimmer}>\n        {loading || leaving ? (\n          <div ref={skeletonRef} aria-hidden=\"true\" className={loading ? undefined : \"pointer-events-none absolute inset-x-0 top-0\"}>\n            {fallback}\n          </div>\n        ) : null}\n      </ShimmerContext.Provider>\n      {loading ? (\n        <span role=\"status\" className=\"sr-only\">\n          {label}\n        </span>\n      ) : (\n        <div ref={contentRef}>{children}</div>\n      )}\n    </div>\n  );\n}\n"}]}