{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A page-preloader curtain that plays once and then uncovers the page.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\n\nexport const PAGE_PRELOADER_VARIANTS = [\n  \"words\",\n  \"stairs\",\n  \"pixel\",\n  \"curtain\",\n] as const;\n\nexport type PagePreloaderVariant = (typeof PAGE_PRELOADER_VARIANTS)[number];\n\nexport type PagePreloaderProps = {\n  /** Whether the overlay is showing. Omit to let the component manage itself. */\n  active?: boolean;\n  /** Tailwind class(es) used to fill the overlay pieces. */\n  background?: string;\n  className?: string;\n  /** Column count for the \"stairs\" and \"pixel\" variants. */\n  columns?: number;\n  /** Renders the overlay `absolute` inside its parent instead of `fixed` full-screen. */\n  container?: boolean;\n  /** Initial `active` value when uncontrolled. */\n  defaultActive?: boolean;\n  /** Hold duration in ms before the exit plays (uncontrolled mode). */\n  duration?: number;\n  /** Fires once the exit transition has fully finished. */\n  onComplete?: () => void;\n  variant?: PagePreloaderVariant;\n  /** Words cycled by the \"words\" variant while holding. */\n  words?: string[];\n};\n\ntype Stage = \"idle\" | \"hold\" | \"exiting\";\n\nconst DEFAULT_DURATION_MS = 1400;\nconst DEFAULT_COLUMNS = 6;\nconst DEFAULT_WORDS = [\"Loading\", \"Almost there\", \"Ready\"];\nconst DEFAULT_BACKGROUND = \"bg-background\";\nconst WORD_STEP_MS = 480;\nconst STAIRS_STAGGER_S = 0.06;\nconst PIXEL_STAGGER_S = 0.012;\nconst CURTAIN_EXIT_S = 0.5;\nconst STAIRS_EXIT_S = 0.55;\nconst PIXEL_EXIT_S = 0.4;\nconst WORDS_EXIT_S = 0.45;\nconst WORD_ROLL_OFFSET_PERCENT = 100;\nconst EASE_ENTER = [0.23, 1, 0.32, 1] as const;\nconst EASE_MOVE = [0.645, 0.045, 0.355, 1] as const;\nconst MS_PER_S = 1000;\nconst PIXEL_SEED = 733;\nconst LCG_MULTIPLIER = 1_664_525;\nconst LCG_INCREMENT = 1_013_904_223;\nconst UINT32_MAX = 0xff_ff_ff_ff;\n\n/** Deterministic seeded shuffle — no `Math.random()`, safe to call during render via `useMemo`. */\nconst seededOrder = (count: number, seed: number): number[] => {\n  let state = seed;\n  const next = () => {\n    state = (state * LCG_MULTIPLIER + LCG_INCREMENT) >>> 0;\n    return state / UINT32_MAX;\n  };\n  const order = Array.from({ length: count }, (_, index) => index);\n  for (let i = order.length - 1; i > 0; i -= 1) {\n    const j = Math.floor(next() * (i + 1));\n    [order[i], order[j]] = [order[j], order[i]];\n  }\n  return order;\n};\n\nconst getExitDurationMs = (\n  variant: PagePreloaderVariant,\n  columns: number\n): number => {\n  switch (variant) {\n    case \"stairs\":\n      return (\n        (STAIRS_EXIT_S + Math.max(0, columns - 1) * STAIRS_STAGGER_S) * MS_PER_S\n      );\n    case \"pixel\": {\n      const total = columns * columns;\n      return (\n        (PIXEL_EXIT_S + Math.max(0, total - 1) * PIXEL_STAGGER_S) * MS_PER_S\n      );\n    }\n    case \"words\":\n      return WORDS_EXIT_S * MS_PER_S;\n    default:\n      return CURTAIN_EXIT_S * MS_PER_S;\n  }\n};\n\ntype VariantProps = {\n  background: string;\n  shouldReduceMotion: boolean;\n  stage: Stage;\n};\n\nconst CurtainVariant = ({\n  background,\n  shouldReduceMotion,\n  stage,\n}: VariantProps) => {\n  const exiting = stage === \"exiting\";\n  const transition = shouldReduceMotion\n    ? { duration: 0 }\n    : { duration: CURTAIN_EXIT_S, ease: EASE_MOVE };\n\n  return (\n    <div aria-hidden=\"true\" className=\"absolute inset-0 flex\">\n      <motion.div\n        animate={\n          exiting\n            ? shouldReduceMotion\n              ? { opacity: 0 }\n              : { opacity: 1, x: \"-100%\" }\n            : { opacity: 1, x: 0 }\n        }\n        className={cn(\"h-full w-1/2\", background)}\n        initial={false}\n        transition={transition}\n      />\n      <motion.div\n        animate={\n          exiting\n            ? shouldReduceMotion\n              ? { opacity: 0 }\n              : { opacity: 1, x: \"100%\" }\n            : { opacity: 1, x: 0 }\n        }\n        className={cn(\"h-full w-1/2\", background)}\n        initial={false}\n        transition={transition}\n      />\n    </div>\n  );\n};\n\nconst StairsVariant = ({\n  background,\n  columns,\n  shouldReduceMotion,\n  stage,\n}: VariantProps & { columns: number }) => {\n  const exiting = stage === \"exiting\";\n\n  return (\n    <div aria-hidden=\"true\" className=\"absolute inset-0 flex\">\n      {Array.from({ length: columns }, (_, index) => (\n        <motion.div\n          animate={\n            exiting\n              ? shouldReduceMotion\n                ? { opacity: 0 }\n                : { opacity: 1, scaleY: 0 }\n              : { opacity: 1, scaleY: 1 }\n          }\n          className={cn(\"h-full flex-1\", background)}\n          initial={false}\n          // biome-ignore lint/suspicious/noArrayIndexKey: columns are positional decorative tiles\n          key={index}\n          style={{ transformOrigin: \"bottom\" }}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : {\n                  delay: exiting ? index * STAIRS_STAGGER_S : 0,\n                  duration: STAIRS_EXIT_S,\n                  ease: EASE_MOVE,\n                }\n          }\n        />\n      ))}\n    </div>\n  );\n};\n\nconst PixelVariant = ({\n  background,\n  columns,\n  shouldReduceMotion,\n  stage,\n}: VariantProps & { columns: number }) => {\n  const exiting = stage === \"exiting\";\n  const total = columns * columns;\n  const order = useMemo(() => seededOrder(total, PIXEL_SEED), [total]);\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"absolute inset-0 grid\"\n      style={{\n        gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n        gridTemplateRows: `repeat(${columns}, minmax(0, 1fr))`,\n      }}\n    >\n      {Array.from({ length: total }, (_, index) => (\n        <motion.div\n          animate={exiting ? { opacity: 0 } : { opacity: 1 }}\n          className={cn(background)}\n          initial={false}\n          // biome-ignore lint/suspicious/noArrayIndexKey: grid cells are positional decorative tiles\n          key={index}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : {\n                  delay: exiting ? (order[index] ?? 0) * PIXEL_STAGGER_S : 0,\n                  duration: PIXEL_EXIT_S,\n                  ease: EASE_ENTER,\n                }\n          }\n        />\n      ))}\n    </div>\n  );\n};\n\nconst WordsVariant = ({\n  background,\n  shouldReduceMotion,\n  stage,\n  wordIndex,\n  words,\n}: VariantProps & { wordIndex: number; words: string[] }) => {\n  const exiting = stage === \"exiting\";\n\n  return (\n    <motion.div\n      animate={\n        exiting\n          ? shouldReduceMotion\n            ? { opacity: 0 }\n            : { opacity: 1, y: \"-100%\" }\n          : { opacity: 1, y: 0 }\n      }\n      aria-hidden=\"true\"\n      className={cn(\n        \"absolute inset-0 flex items-center justify-center overflow-hidden\",\n        background\n      )}\n      initial={false}\n      transition={\n        shouldReduceMotion\n          ? { duration: 0 }\n          : { duration: WORDS_EXIT_S, ease: EASE_MOVE }\n      }\n    >\n      <div className=\"relative h-8 overflow-hidden\">\n        <AnimatePresence mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            className=\"block font-medium text-foreground text-xl\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, y: -WORD_ROLL_OFFSET_PERCENT }\n            }\n            initial={\n              shouldReduceMotion\n                ? { opacity: 0 }\n                : { opacity: 0, y: WORD_ROLL_OFFSET_PERCENT }\n            }\n            key={words[wordIndex] ?? wordIndex}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n            }\n          >\n            {words[wordIndex]}\n          </motion.span>\n        </AnimatePresence>\n      </div>\n    </motion.div>\n  );\n};\n\nconst PagePreloader = ({\n  active: activeProp,\n  background = DEFAULT_BACKGROUND,\n  className,\n  columns = DEFAULT_COLUMNS,\n  container = false,\n  defaultActive = true,\n  duration = DEFAULT_DURATION_MS,\n  onComplete,\n  variant = \"curtain\",\n  words = DEFAULT_WORDS,\n}: PagePreloaderProps) => {\n  const reduceMotionPreference = useReducedMotion();\n  const shouldReduceMotion = Boolean(reduceMotionPreference);\n  const isControlled = activeProp !== undefined;\n  const [internalActive, setInternalActive] = useState(defaultActive);\n  const active = isControlled ? Boolean(activeProp) : internalActive;\n  const [stage, setStage] = useState<Stage>(active ? \"hold\" : \"idle\");\n  const [wordIndex, setWordIndex] = useState(0);\n\n  const finish = useCallback(() => {\n    setStage(\"idle\");\n    if (!isControlled) {\n      setInternalActive(false);\n    }\n    onComplete?.();\n  }, [isControlled, onComplete]);\n\n  // Sync stage with the active flag: entering re-arms the hold, and a\n  // controlled dismissal while holding starts the exit.\n  useEffect(() => {\n    if (active) {\n      setStage((current) => (current === \"exiting\" ? current : \"hold\"));\n      setWordIndex(0);\n      return;\n    }\n    setStage((current) => (current === \"hold\" ? \"exiting\" : current));\n  }, [active]);\n\n  // Uncontrolled auto-hold: exit on its own after `duration`.\n  useEffect(() => {\n    if (isControlled || stage !== \"hold\") {\n      return;\n    }\n    const timer = setTimeout(\n      () => setStage(\"exiting\"),\n      shouldReduceMotion ? 0 : duration\n    );\n    return () => clearTimeout(timer);\n  }, [duration, isControlled, shouldReduceMotion, stage]);\n\n  // Words cycling while holding.\n  useEffect(() => {\n    if (variant !== \"words\" || stage !== \"hold\" || shouldReduceMotion) {\n      return;\n    }\n    if (words.length <= 1) {\n      return;\n    }\n    const timer = setInterval(() => {\n      setWordIndex((index) => (index + 1) % words.length);\n    }, WORD_STEP_MS);\n    return () => clearInterval(timer);\n  }, [shouldReduceMotion, stage, variant, words.length]);\n\n  // Exit finishes after its computed duration.\n  useEffect(() => {\n    if (stage !== \"exiting\") {\n      return;\n    }\n    const ms = shouldReduceMotion ? 0 : getExitDurationMs(variant, columns);\n    const timer = setTimeout(finish, ms);\n    return () => clearTimeout(timer);\n  }, [columns, finish, shouldReduceMotion, stage, variant]);\n\n  // Lock body scroll only for full-screen overlays.\n  useEffect(() => {\n    if (container || stage === \"idle\") {\n      return;\n    }\n    const original = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = original;\n    };\n  }, [container, stage]);\n\n  if (stage === \"idle\") {\n    return null;\n  }\n\n  return (\n    <div\n      aria-live=\"polite\"\n      className={cn(\n        \"overflow-hidden\",\n        container ? \"absolute inset-0 z-40\" : \"fixed inset-0 z-[100]\",\n        className\n      )}\n      role=\"status\"\n    >\n      <span className=\"sr-only\">Loading</span>\n      {variant === \"curtain\" && (\n        <CurtainVariant\n          background={background}\n          shouldReduceMotion={shouldReduceMotion}\n          stage={stage}\n        />\n      )}\n      {variant === \"stairs\" && (\n        <StairsVariant\n          background={background}\n          columns={columns}\n          shouldReduceMotion={shouldReduceMotion}\n          stage={stage}\n        />\n      )}\n      {variant === \"pixel\" && (\n        <PixelVariant\n          background={background}\n          columns={columns}\n          shouldReduceMotion={shouldReduceMotion}\n          stage={stage}\n        />\n      )}\n      {variant === \"words\" && (\n        <WordsVariant\n          background={background}\n          shouldReduceMotion={shouldReduceMotion}\n          stage={stage}\n          wordIndex={wordIndex}\n          words={words}\n        />\n      )}\n    </div>\n  );\n};\n\nexport default PagePreloader;\n","path":"index.tsx","target":"components/smoothui/page-preloader/index.tsx","type":"registry:ui"}],"name":"page-preloader","registryDependencies":[],"title":"Page Preloader","type":"registry:ui"}