{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "component-wall",
  "title": "Component Wall",
  "author": "AlgorizeTech",
  "description": "Ask the user to pick one of 2 component wall variants: https://avaraui.com/view/component-wall. This item holds 2 of them as looks of one component (center, split). Skip the question if the user already named one. A landing hero for a component library: a headline that blurs in word by word and keeps one word turning, a lead, an install command that names whichever card a camera has just landed on, two actions, a line of trust and the tools it is built with in batches — centred between two walls of screenshots that never end, one camera resting high and the other low, or on the left of a single wall.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://avaraui.com/r/avatar-stack.json",
    "https://avaraui.com/r/copy-button.json",
    "https://avaraui.com/r/half-stars.json",
    "https://avaraui.com/r/icons.json",
    "https://avaraui.com/r/image-wall.json",
    "https://avaraui.com/r/tech-stack.json",
    "https://avaraui.com/r/text-animation.json",
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/ComponentWallBlock.tsx",
      "content": "\"use client\";\n\nimport React, {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { Terminal } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { AvatarStack, type StackAvatar } from \"./AvatarStackBlock\";\nimport { CopyButton } from \"./CopyButtonBlock\";\nimport { HalfStars } from \"./HalfStarsBlock\";\nimport { ImageWall, type ImageWallProps, type WallItem } from \"./ImageWallBlock\";\nimport { TextAnimation } from \"./TextAnimationBlock\";\n\n// ==========================================\n// COMPONENT WALL\n// A landing hero for a component library. On the right, an ImageWall of\n// screenshots that never ends, its camera gliding from card to card. On the\n// left, the copy: a small badge, a headline that blurs in word by word (one\n// word in the primary colour, one that takes turns), a lead, an install\n// command whose component name follows the card the camera is resting on,\n// two actions and a line of trust. The tools it is built with — a\n// TechStack, passed in as `stack` — sit over the lower wall on desktop,\n// where the wall fills the right side and fades under the copy; on the\n// stacked layouts the wall is a band under the copy and the tools follow it.\n// `layout=\"center\"` puts the copy in the middle between two walls — one\n// camera resting high, the other low — with the tools under the copy.\n// ==========================================\n\nexport interface ComponentWallItem extends WallItem {\n  /**\n   * What the install command calls this card — `{item}` in `install.command`\n   * becomes this. Defaults to the title in kebab-case.\n   */\n  slug?: string;\n}\n\nexport interface ComponentWallProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"onFocus\" | \"draggable\">,\n    Pick<ImageWallProps, \"columns\" | \"interval\" | \"intervalTime\" | \"order\" | \"zoom\" | \"mobileZoom\" | \"intro\" | \"onFocus\" | \"gap\" | \"stagger\" | \"draggable\" | \"drift\" | \"cardDim\" | \"highlightFocus\" | \"cardShadow\" | \"onItemClick\"> {\n  /** The small link above the headline. */\n  badge?: { label: string; href?: string };\n  /**\n   * The headline, revealed word by word. Write `{word}` where `headingWords`\n   * take turns: `\"Build interactive {word}.\"`.\n   */\n  heading?: string;\n  /** Words that take turns at `{word}`, each blurring in the way the headline did. */\n  headingWords?: string[];\n  /** Milliseconds each of `headingWords` stays. */\n  wordInterval?: number;\n  /** A word of the headline set in the primary colour. */\n  accent?: string;\n  /** The lead under the headline. Rendered inside a `<p>`, so pass phrasing content. */\n  description?: React.ReactNode;\n  /**\n   * The install line. Put `{item}` where the focused card's `slug` goes:\n   * `\"npx shadcn@latest add @avara-ui/{item}\"`. The whole resolved\n   * command is what the copy button copies.\n   */\n  install?: { command: string };\n  primaryAction?: { label: string; href: string; icon?: React.ReactNode };\n  secondaryAction?: { label: string; href: string; icon?: React.ReactNode };\n  /** The trust line under the actions: the sentence, the faces and the stars. */\n  trust?: { text: string; avatars?: StackAvatar[]; rating?: number };\n  /** The cards on the wall, in reading order down the columns. */\n  items: ComponentWallItem[];\n  /**\n   * The tools the library is built with — a `TechStack`. Over the lower wall\n   * on desktop, under the band on smaller screens; in the centred layout it\n   * closes the copy column, where `variant=\"compact\"` (marks only) fits.\n   */\n  stack?: React.ReactNode;\n  /**\n   * `split`: the copy on the left, the wall on the right. `center`: the copy\n   * in the middle between two walls, the left camera resting high and the\n   * right one low, taking turns; the tools sit under the copy. The walls\n   * split `items`, the first half on the left and the rest on the right.\n   * Below `lg` there is a single wall instead: a band of every card\n   * between the copy and the tools. `center-background`: keeps the walls\n   * anchored in the background layer behind the copy across all screens.\n   */\n  layout?: \"split\" | \"center\" | \"center-background\";\n  /**\n   * When true (or with layout=\"center-background\"), the animated screenshot\n   * walls stay in the background layer behind the copy on small screens instead\n   * of dropping below the action buttons as a separate band.\n   */\n  persistentBackground?: boolean;\n  /**\n   * When true (in layout=\"center\" or layout=\"center-background\"), renders ONE single\n   * continuous background wall across the entire hero instead of splitting into two separate walls.\n   */\n  singleBackground?: boolean;\n  /**\n   * Alias for `singleBackground`. When true, renders one full-width background wall.\n   */\n  singleWall?: boolean;\n  /**\n   * Whether to show the background/wall image animation on mobile and small screens (<lg).\n   * When `false`, the image animation is completely hidden on small screens across all layouts\n   * (split, center, center-background), leaving only the clean content copy and tools on phones.\n   * Defaults to `true`.\n   */\n  showMobileWall?: boolean;\n  /**\n   * Alias for `showMobileWall`. Set to `false` to hide wall animations on small screens across all layouts.\n   */\n  mobileBackground?: boolean;\n  /**\n   * Centred layout only: whether the single wall band shows below `lg`.\n   * Deprecated in favor of `showMobileWall` which works across all layouts.\n   */\n  compactWall?: boolean;\n  /** Pass `next/link` to keep client-side navigation. */\n  linkComponent?: React.ElementType;\n}\n\nconst kebab = (s: string) =>\n  s\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-|-$/g, \"\");\n\nconst ITEM_TOKEN = \"{item}\";\nconst WORD_TOKEN = \"{word}\";\nconst bare = (w: string) => w.replace(/[^\\p{L}\\p{N}]/gu, \"\").toLowerCase();\n\n// The same entrance as the headline's blur-in-up, replayed each time the word\n// changes (`cw-word-in` / `cw-word-out`). The lead, the install line, the\n// actions and the trust line rise once, in order, 0.1s apart, starting the\n// moment the headline does (`cw-rise`, `--cw-i` is each one's place in line).\n// With reduced motion nothing moves and the swaps are instant.\nconst STYLES = `\n@keyframes cw-rise { from { opacity: 0; transform: translateY(14px); } }\n@keyframes cw-word-in { from { opacity: 0; transform: translateY(20px); filter: blur(8px); } }\n@keyframes cw-word-out { to { opacity: 0; transform: translateY(-20px); filter: blur(8px); } }\n@keyframes cw-name-in { from { opacity: 0; transform: translateY(110%); } }\n@keyframes cw-name-out { to { opacity: 0; transform: translateY(-110%); } }\n@media (prefers-reduced-motion: no-preference) {\n  .cw-rise-group:not([data-in-view]) .cw-rise { opacity: 0; transform: translateY(14px); }\n  .cw-rise-group[data-in-view] .cw-rise { animation: cw-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) calc(var(--cw-i) * 0.1s) backwards; }\n  .cw-tools-in { animation: cw-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.5s backwards; }\n}\n.cw-word-in { animation: cw-word-in 0.6s cubic-bezier(0, 0, 0.58, 1) backwards; }\n.cw-word-out { animation: cw-word-out 0.6s cubic-bezier(0, 0, 0.58, 1) forwards; }\n.cw-name-in { animation: cw-name-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards; }\n.cw-name-out { animation: cw-name-out 0.45s cubic-bezier(0.22, 1, 0.36, 1) forwards; }\n`;\n\n// Where each camera rests in the centred layout, from `lg` up: the left one\n// high and towards the outside, the right one low, so the two cards never\n// mirror each other across the copy.\nconst START_ANCHOR = { x: 0.27, y: 0.28 };\nconst END_ANCHOR = { x: 0.73, y: 0.72 };\n/** Each wall is half the width, so the centred layout rests further out. */\nconst CENTER_ZOOM = 0.9;\nconst MIDDLE = { x: 0.5, y: 0.5 };\n\n/** Null until measured on the client, so nothing breakpoint-specific mounts early. */\nfunction useMinWidth(px: number) {\n  const [matches, setMatches] = useState<boolean | null>(null);\n  useEffect(() => {\n    const mq = window.matchMedia(`(min-width: ${px}px)`);\n    const update = () => setMatches(mq.matches);\n    update();\n    mq.addEventListener(\"change\", update);\n    return () => mq.removeEventListener(\"change\", update);\n  }, [px]);\n  return matches;\n}\n\nconst REDUCED_MOTION = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReducedMotion = (onChange: () => void) => {\n  const mq = window.matchMedia(REDUCED_MOTION);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n};\n/** The visitor's reduced-motion setting; false while server-rendering. */\nfunction useReducedMotion() {\n  return useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_MOTION).matches,\n    () => false,\n  );\n}\n\n/** True from the first time 30% of the element is in view, and for good. */\nfunction useInViewOnce<T extends Element>() {\n  const ref = useRef<T>(null);\n  const [inView, setInView] = useState(false);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || inView) return;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return;\n        setInView(true);\n        io.disconnect();\n      },\n      { threshold: 0.3 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [inView]);\n  return [ref, inView] as const;\n}\n\ninterface SwapLayer<T> {\n  id: number;\n  key: React.Key;\n  value: T;\n  /** Plays the way in — every layer but the first. */\n  enter: boolean;\n  leaving: boolean;\n  /** Where a leaving layer was, pinned there out of the flow. */\n  pop?: React.CSSProperties;\n}\n\n/**\n * One value that gives way to the next: the new one comes in while the old one\n * plays its way out on top of where it was, lifted out of the flow so the\n * space closes up to the new one at once. `drop` removes a layer once it has\n * gone; a timer does it too, in case its animation never ends. With reduced\n * motion the swap is instant.\n */\nfunction useSwap<T>(key: React.Key, value: T, reduceMotion: boolean, exitMs: number) {\n  const [layers, setLayers] = useState<SwapLayer<T>[]>(() => [{ id: 0, key, value, enter: false, leaving: false }]);\n  const nodes = useRef(new Map<number, HTMLElement>());\n  const timers = useRef(new Map<number, number>());\n  const nextId = useRef(1);\n\n  const drop = useCallback((id: number) => {\n    window.clearTimeout(timers.current.get(id));\n    timers.current.delete(id);\n    setLayers((current) => current.filter((layer) => layer.id !== id));\n  }, []);\n\n  useLayoutEffect(() => {\n    if (layers[layers.length - 1]?.key === key) return;\n    const next = { id: nextId.current++, key, value, enter: !reduceMotion, leaving: false };\n    if (reduceMotion) {\n      setLayers([next]);\n      return;\n    }\n    setLayers([\n      ...layers.map((layer) => {\n        if (layer.leaving) return layer;\n        const node = nodes.current.get(layer.id);\n        const pop: React.CSSProperties | undefined = node && {\n          position: \"absolute\",\n          top: node.offsetTop,\n          left: node.offsetLeft,\n          width: node.offsetWidth,\n          height: node.offsetHeight,\n        };\n        return { ...layer, leaving: true, pop };\n      }),\n      next,\n    ]);\n  }, [key, value, layers, reduceMotion]);\n\n  useEffect(() => {\n    for (const layer of layers) {\n      if (layer.leaving && !timers.current.has(layer.id)) {\n        timers.current.set(layer.id, window.setTimeout(() => drop(layer.id), exitMs + 100));\n      }\n    }\n  }, [layers, exitMs, drop]);\n  useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach((t) => window.clearTimeout(t));\n  }, []);\n\n  const register = useCallback(\n    (id: number) => (node: HTMLElement | null) => {\n      if (node) nodes.current.set(id, node);\n      else nodes.current.delete(id);\n    },\n    [],\n  );\n\n  /** What a layer shows: the current value, or what it showed when it started leaving. */\n  const valueOf = (layer: SwapLayer<T>) => (!layer.leaving && layer.key === key ? value : layer.value);\n\n  return { layers, drop, register, valueOf };\n}\n\n/** A word or a name that swaps in place — `cw-word-*` or `cw-name-*`. */\nfunction SwapText({\n  text,\n  motion,\n  exitMs,\n  reduceMotion,\n  className,\n}: {\n  text: string;\n  motion: \"word\" | \"name\";\n  exitMs: number;\n  reduceMotion: boolean;\n  className?: string;\n}) {\n  const { layers, drop, register, valueOf } = useSwap(text, text, reduceMotion, exitMs);\n  return (\n    <>\n      {layers.map((layer) => (\n        <span\n          key={layer.id}\n          ref={register(layer.id)}\n          style={layer.pop}\n          onAnimationEnd={(e) => {\n            if (layer.leaving && e.target === e.currentTarget) drop(layer.id);\n          }}\n          className={cn(\n            className,\n            layer.leaving ? `cw-${motion}-out` : layer.enter && `cw-${motion}-in`,\n          )}\n        >\n          {valueOf(layer)}\n        </span>\n      ))}\n    </>\n  );\n}\n\n// ------------------------------------------\n// Root\n// ------------------------------------------\nexport const ComponentWall = forwardRef<HTMLElement, ComponentWallProps>(\n  (\n    {\n      badge,\n      heading = \"\",\n      headingWords,\n      wordInterval = 2400,\n      accent,\n      description,\n      install,\n      primaryAction,\n      secondaryAction,\n      trust,\n      items,\n      stack,\n      columns,\n      gap,\n      stagger,\n      interval,\n      intervalTime,\n      order,\n      zoom,\n      mobileZoom,\n      draggable,\n      drift,\n      cardDim,\n      highlightFocus,\n      cardShadow,\n      intro,\n      onFocus,\n      onItemClick,\n      layout = \"split\",\n      persistentBackground = false,\n      singleBackground = false,\n      singleWall,\n      compactWall = true,\n      showMobileWall,\n      mobileBackground,\n      linkComponent: Link = \"a\",\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const reduceMotion = useReducedMotion();\n    const resolvedIntro = intro ?? 1800;\n    const wallStagger = stagger !== undefined ? stagger : 72;\n    const wallGap = gap !== undefined ? gap : 16;\n    const isSingleWall = Boolean(singleBackground || singleWall);\n    const isPersistent = persistentBackground || layout === \"center-background\";\n    const centered = layout === \"center\" || isPersistent;\n    const desktop = useMinWidth(1024);\n    const isMobileWallVisible =\n      showMobileWall !== undefined\n        ? showMobileWall\n        : mobileBackground !== undefined\n          ? mobileBackground\n          : compactWall !== undefined\n            ? compactWall\n            : true;\n\n    // ---- The word that takes turns in the headline.\n    const [wordIndex, setWordIndex] = useState(0);\n    const wordCount = headingWords?.length ?? 0;\n    useEffect(() => {\n      if (wordCount < 2 || wordInterval <= 0) return;\n      const t = window.setInterval(() => setWordIndex((i) => (i + 1) % wordCount), wordInterval);\n      return () => window.clearInterval(t);\n    }, [wordCount, wordInterval]);\n    const currentWord = headingWords?.[wordIndex] ?? \"\";\n\n    const renderWord = useCallback(\n      (word: string) => {\n        const at = word.indexOf(WORD_TOKEN);\n        if (at !== -1 && wordCount > 0) {\n          return (\n            <>\n              {word.slice(0, at)}\n              <span className=\"relative inline-grid align-baseline\">\n                <SwapText\n                  text={currentWord}\n                  motion=\"word\"\n                  exitMs={600}\n                  reduceMotion={reduceMotion}\n                  className=\"col-start-1 row-start-1 inline-block\"\n                />\n              </span>\n              {word.slice(at + WORD_TOKEN.length)}\n            </>\n          );\n        }\n        if (accent && bare(word) === bare(accent)) return <span className=\"text-primary\">{word}</span>;\n        return word;\n      },\n      [wordCount, currentWord, accent, reduceMotion],\n    );\n\n    // The card the camera is resting on, so the install line can name it.\n    const [focus, setFocus] = useState(0);\n    const handleFocus = useCallback(\n      (i: number) => {\n        setFocus(i);\n        onFocus?.(i);\n      },\n      [onFocus],\n    );\n    const slug = useMemo(() => {\n      const item = items[focus];\n      return item ? (item.slug ?? kebab(item.title)) : \"\";\n    }, [items, focus]);\n\n    // ---- The two walls of the centred layout split the cards: the first half\n    // on the left, the second half on the right, so each wall draws half as\n    // many and the two never show the same thing. (Under four cards a half\n    // would be a single card, so both walls then deal the whole set, the\n    // second from halfway through.) The right camera lands half an interval\n    // after the left one's, and the install line names whichever card was\n    // landed on last. Until its camera first moves it only ever reports its\n    // opening card, which is skipped, so the line opens on the left wall's card.\n    const split = items.length >= 4;\n    const shift = split ? Math.ceil(items.length / 2) : Math.floor(items.length / 2);\n    const startItems = useMemo(() => (split ? items.slice(0, shift) : items), [items, split, shift]);\n    const endItems = useMemo(\n      () => (split ? items.slice(shift) : [...items.slice(shift), ...items.slice(0, shift)]),\n      [items, split, shift],\n    );\n    const endMoved = useRef(false);\n    const handleEndFocus = useCallback(\n      (i: number) => {\n        if (i === 0 && !endMoved.current) return;\n        endMoved.current = true;\n        handleFocus((i + shift) % items.length);\n      },\n      [handleFocus, shift, items.length],\n    );\n    const restInterval = typeof interval === \"boolean\" ? (interval ? 4500 : 0) : (interval ?? 4500);\n\n    // `npx shadcn@latest add @avara-ui/{item}` splits into what comes before\n    // the name and after it; the last word before it (`@avara-ui/`) stays on\n    // phones, the rest of the prefix only shows from `sm` up.\n    const command = useMemo(() => {\n      if (!install) return null;\n      const at = install.command.indexOf(ITEM_TOKEN);\n      const before = at === -1 ? install.command : install.command.slice(0, at);\n      const after = at === -1 ? \"\" : install.command.slice(at + ITEM_TOKEN.length);\n      const cut = before.lastIndexOf(\" \");\n      return {\n        head: cut === -1 ? \"\" : before.slice(0, cut + 1),\n        tail: cut === -1 ? before : before.slice(cut + 1),\n        name: at === -1 ? \"\" : slug,\n        after,\n        full: at === -1 ? install.command : before + slug + after,\n      };\n    }, [install, slug]);\n\n    const isSmallScreen = desktop === false;\n    const wallProps = {\n      columns,\n      gap: wallGap,\n      stagger: wallStagger,\n      interval,\n      intervalTime,\n      order,\n      zoom: zoom ?? (centered ? CENTER_ZOOM : undefined),\n      mobileZoom: isSmallScreen ? (mobileZoom ?? (isPersistent ? 0.30 : 0.32)) : undefined,\n      draggable,\n      drift,\n      cardDim: cardDim ?? 0.55,\n      highlightFocus: highlightFocus ?? true,\n      cardShadow: cardShadow ?? false,\n      onItemClick,\n    };\n\n    // Each line that rises takes the next place in line, skipping the absent ones.\n    let risen = 0;\n    const riseAt = () => ({ \"--cw-i\": risen++ }) as React.CSSProperties;\n    const [copyRef, copyInView] = useInViewOnce<HTMLDivElement>();\n\n    const copy = (\n      <div\n        ref={copyRef}\n        data-in-view={copyInView || undefined}\n        className={cn(\n          \"cw-rise-group pointer-events-auto relative flex w-full flex-col\",\n          centered\n            ? \"max-w-[34rem] items-center py-10 text-center lg:max-w-[30rem] lg:py-20 xl:max-w-[36rem]\"\n            : \"items-start py-12 lg:w-[46%] lg:py-20 xl:w-[44%]\",\n        )}\n      >\n        {badge && (\n          <div style={riseAt()} className=\"cw-rise mb-6\">\n            <Link\n              href={badge.href ?? \"#\"}\n              className=\"inline-flex items-center gap-2 rounded-full border border-border bg-background/70 py-1 pl-2.5 pr-3 text-[13px] font-medium text-muted-foreground outline-none backdrop-blur transition-colors hover:border-foreground/30 hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/50\"\n            >\n              <span className=\"size-1.5 rounded-full bg-primary\" aria-hidden />\n              {badge.label}\n            </Link>\n          </div>\n        )}\n\n        {heading ? (\n          <TextAnimation\n            as=\"h1\"\n            variant=\"blur-in-up\"\n            text={heading}\n            renderWord={renderWord}\n            delay={0}\n            className={cn(\n              \"max-w-[14ch] gap-y-0 font-serif text-[2.6rem] font-medium leading-[1.02] tracking-[-0.02em] text-foreground sm:text-[3.4rem] xl:text-[4rem]\",\n              centered && \"justify-center\",\n            )}\n          />\n        ) : null}\n\n        {description && (\n          <p style={riseAt()} className=\"cw-rise mt-6 max-w-[39em] text-pretty text-[15px] leading-[1.65] text-muted-foreground sm:text-base\">\n            {description}\n          </p>\n        )}\n\n        {command && (\n          <div style={riseAt()} className=\"cw-rise mt-8 w-full max-w-xl text-left\">\n            <div className=\"flex h-12 items-center gap-3 rounded-xl border border-border bg-muted/40 pl-3.5 pr-1.5 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.6)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06)]\">\n              <Terminal size={15} className=\"shrink-0 text-muted-foreground\" aria-hidden />\n              <span className=\"min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono text-[13px] leading-none text-foreground sm:text-sm\">\n                <span className=\"hidden text-muted-foreground sm:inline\">{command.head}</span>\n                <span className=\"text-muted-foreground\">{command.tail}</span>\n                {command.name && (\n                  <span className=\"relative inline-grid overflow-hidden align-bottom font-semibold\" aria-live=\"polite\">\n                    <SwapText\n                      text={command.name}\n                      motion=\"name\"\n                      exitMs={450}\n                      reduceMotion={reduceMotion}\n                      className=\"col-start-1 row-start-1 inline-block\"\n                    />\n                  </span>\n                )}\n                {command.after && <span className=\"text-muted-foreground\">{command.after}</span>}\n              </span>\n              <CopyButton value={command.full} iconOnly variant=\"ghost\" size=\"sm\" className=\"rounded-lg\" />\n            </div>\n          </div>\n        )}\n\n        {(primaryAction || secondaryAction) && (\n          <div\n            style={riseAt()}\n            className={cn(\"cw-rise mt-5 flex w-full flex-col gap-2.5 sm:flex-row sm:items-center\", centered && \"sm:justify-center\")}\n          >\n            {primaryAction && (\n              <Link\n                href={primaryAction.href}\n                className=\"inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-primary px-6 text-[15px] font-semibold text-primary-foreground outline-none transition-[transform,background-color] duration-200 hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.98]\"\n              >\n                {primaryAction.icon}\n                {primaryAction.label}\n              </Link>\n            )}\n            {secondaryAction && (\n              <Link\n                href={secondaryAction.href}\n                className=\"inline-flex h-12 items-center justify-center gap-2 rounded-xl border border-border bg-background px-6 text-[15px] font-semibold text-foreground outline-none transition-[transform,background-color] duration-200 hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.98]\"\n              >\n                {secondaryAction.icon}\n                {secondaryAction.label}\n              </Link>\n            )}\n          </div>\n        )}\n\n        {trust && (\n          <div\n            style={riseAt()}\n            className={cn(\n              \"cw-rise mt-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-5\",\n              centered ? \"items-center\" : \"items-start\",\n            )}\n          >\n            {trust.avatars && trust.avatars.length > 0 && (\n              <AvatarStack\n                avatars={trust.avatars}\n                variant=\"shift\"\n                size=\"lg\"\n                max={4}\n                showTooltip\n                ringClassName=\"ring-background\"\n                className=\"m-0 p-0 shrink-0\"\n              />\n            )}\n            <div className={cn(\"flex flex-col gap-1 sm:border-l sm:border-border sm:pl-5\", centered && \"items-center sm:items-start sm:text-left\")}>\n              {typeof trust.rating === \"number\" && (\n                <div className=\"flex items-center gap-2\">\n                  <HalfStars rating={trust.rating} size={15} showText={false} />\n                  <span className=\"text-sm font-semibold tabular-nums leading-none text-foreground\">{trust.rating.toFixed(1)}</span>\n                </div>\n              )}\n              <p className={cn(\"max-w-[27em] text-[13px] leading-snug text-muted-foreground\", centered && \"text-balance\")}>{trust.text}</p>\n            </div>\n          </div>\n        )}\n\n        {/* ---- Centred, below `lg`: the one wall, a full-width band between the\n            copy and the tools. It deals every card, being the only wall. The\n            box holds its height from the first paint; the wall mounts once the\n            breakpoint is known, so a desktop never loads it. ---- */}\n        {centered && isMobileWallVisible && !isPersistent && (\n          <div\n            className=\"mt-10 h-64 w-screen max-w-none shrink-0 self-center sm:h-80 lg:hidden\"\n            aria-hidden={desktop !== false || undefined}\n          >\n            {desktop === false && (\n              <ImageWall\n                {...wallProps}\n                items={items}\n                intro={resolvedIntro}\n                anchor={MIDDLE}\n                onFocus={handleFocus}\n                className=\"h-full [mask-image:linear-gradient(to_bottom,transparent,black_18%,black_82%,transparent)]\"\n              />\n            )}\n          </div>\n        )}\n\n        {/* ---- Centred: the tools close the column, rising after the trust line ---- */}\n        {centered && stack && (\n          <div style={riseAt()} className=\"cw-rise mt-8 w-full max-w-md lg:mt-12\">\n            {stack}\n          </div>\n        )}\n      </div>\n    );\n\n    if (centered) {\n      return (\n        <section\n          ref={ref}\n          className={cn(\"relative w-full overflow-hidden bg-background text-foreground\", className)}\n          {...props}\n        >\n          <style>{STYLES}</style>\n          <div\n            className={cn(\n              \"relative flex w-full flex-col items-center lg:min-h-[46rem] lg:justify-center\",\n              isPersistent && isMobileWallVisible && \"min-h-[44rem]\",\n            )}\n          >\n            {isSingleWall ? (\n              /* ---- Single full-width background wall ---- */\n              (desktop || (isPersistent && isMobileWallVisible)) && (\n                <ImageWall\n                  {...wallProps}\n                  items={items}\n                  intro={resolvedIntro}\n                  anchor={MIDDLE}\n                  onFocus={handleFocus}\n                  className={cn(\n                    \"absolute inset-0 h-full w-full\",\n                    isPersistent && \"opacity-60 sm:opacity-90 lg:opacity-100\",\n                    !isMobileWallVisible && \"hidden lg:block\",\n                  )}\n                />\n              )\n            ) : (\n              /* ---- From `lg` (or all screens when persistentBackground is active and mobile wall is enabled): the left wall ---- */\n              (desktop || (isPersistent && isMobileWallVisible)) && (\n                <ImageWall\n                  {...wallProps}\n                  items={startItems}\n                  intro={resolvedIntro}\n                  anchor={START_ANCHOR}\n                  onFocus={handleFocus}\n                  className={cn(\n                    \"absolute inset-y-0 left-0 h-auto w-1/2 [mask-image:linear-gradient(to_right,black_35%,transparent_68%)]\",\n                    isPersistent && \"opacity-60 sm:opacity-90 lg:opacity-100\",\n                    !isMobileWallVisible && \"hidden lg:block\",\n                  )}\n                />\n              )\n            )}\n\n            {/* ---- The copy, over a soft wash of the background where the walls meet ---- */}\n            <div className=\"pointer-events-none relative z-30 flex w-full justify-center px-6 md:px-10\">\n              <div\n                className={cn(\n                  \"absolute inset-y-0 left-1/2 w-full max-w-[52rem] -translate-x-1/2 bg-[radial-gradient(closest-side,var(--background)_60%,transparent)]\",\n                  (!isPersistent || !isMobileWallVisible) && \"hidden lg:block\",\n                )}\n                aria-hidden\n              />\n              {copy}\n            </div>\n\n            {/* ---- From `lg` (or all screens when persistentBackground is active and mobile wall is enabled): the right wall (only when dual walls) ---- */}\n            {!isSingleWall && (desktop || (isPersistent && isMobileWallVisible)) && (\n              <ImageWall\n                {...wallProps}\n                items={endItems}\n                intro={resolvedIntro + restInterval / 2}\n                anchor={END_ANCHOR}\n                onFocus={handleEndFocus}\n                className={cn(\n                  \"absolute inset-y-0 right-0 h-auto w-1/2 [mask-image:linear-gradient(to_left,black_35%,transparent_68%)]\",\n                  isPersistent && \"opacity-60 sm:opacity-90 lg:opacity-100\",\n                  !isMobileWallVisible && \"hidden lg:block\",\n                )}\n              />\n            )}\n          </div>\n        </section>\n      );\n    }\n\n    return (\n      <section\n        ref={ref}\n        className={cn(\"relative w-full overflow-hidden bg-background text-foreground\", className)}\n        {...props}\n      >\n        <style>{STYLES}</style>\n        <div className=\"relative flex w-full flex-col lg:min-h-[44rem] lg:flex-row lg:items-center\">\n          {/* ---- Copy ---- */}\n          <div className=\"relative z-30 mx-auto flex w-full max-w-[98rem] items-center px-6 md:px-10 xl:pl-20\">{copy}</div>\n\n          {/* ---- The wall: a band under the copy, or the whole right side fading under it ---- */}\n          {(desktop || isMobileWallVisible) && (\n            <ImageWall\n              {...wallProps}\n              items={items}\n              intro={resolvedIntro}\n              onFocus={handleFocus}\n              className={cn(\n                \"h-80 [mask-image:linear-gradient(to_left,black_85%,transparent_100%)] sm:h-96 lg:absolute lg:inset-y-0 lg:right-0 lg:h-auto lg:w-[56%] lg:[mask-image:linear-gradient(to_left,black_72%,transparent_100%)] xl:w-[58%]\",\n                !isMobileWallVisible && \"hidden lg:block\",\n              )}\n            />\n          )}\n\n          {/* ---- The tools, over the lower part of the wall ---- */}\n          {stack && (\n            <div className=\"pointer-events-none relative z-20 lg:absolute lg:bottom-0 lg:right-0 lg:w-[56%] xl:w-[58%]\">\n              {/* On desktop the wall blurs and fades out under the tools: a blur that\n                  strengthens towards the bottom, under a wash of the background. On\n                  the stacked layouts the tools simply follow the band. */}\n              <div className=\"absolute inset-x-0 bottom-0 hidden h-[12rem] backdrop-blur-md [mask-image:linear-gradient(to_top,black_45%,transparent_100%)] lg:block\" aria-hidden />\n              <div className=\"absolute inset-x-0 bottom-0 hidden h-[12rem] bg-gradient-to-t from-background/90 from-30% via-background/70 to-transparent lg:block\" aria-hidden />\n              {/* On desktop it sits a little way up the wall, in view under the copy —\n                  the padding is a share of the strip's width, which is proportional\n                  to the wall's height. */}\n              <div\n                // Arrives with the last of the copy, so nothing shows ahead of the headline.\n                className=\"cw-tools-in pointer-events-auto relative w-full py-7 px-6 md:pr-0 md:pl-10 lg:pb-[7%] lg:pl-12 lg:pt-0 xl:pl-24\"\n              >\n                {stack}\n              </div>\n            </div>\n          )}\n        </div>\n      </section>\n    );\n},\n);\nComponentWall.displayName = \"ComponentWall\";\n\nexport default ComponentWall;\n",
      "type": "registry:component",
      "target": "components/custom/blocks/ComponentWallBlock.tsx"
    }
  ],
  "docs": "Named export: ComponentWall (plus the ComponentWallItem type). Composed from the image-wall, text-animation, copy-button, avatar-stack and half-stars items, which install alongside it, and takes a tech-stack in its `stack` slot. Pass `badge` ({ label, href? }), `heading` (a string, revealed word by word; write `{word}` where `headingWords` take turns every `wordInterval` ms, and name one word in `accent` for the primary colour), `description` (a string or inline JSX — it renders inside a <p>), `install` ({ command } — write `{item}` where the focused card's `slug` goes, e.g. `npx shadcn@latest add @avara-ui/{item}`), `primaryAction`/`secondaryAction` ({ label, href, icon? }), `trust` ({ text, avatars?, rating? }), `items` ({ title, image, slug? }[] — every screenshot sits in the same 493:332 frame; slug defaults to the title in kebab-case) and `stack` (a node — a <TechStack> of <TechStackGroup>s of <TechStackTool name icon />: the tools over the lower wall on desktop and under the band on phones; in the centred layout it closes the copy column, where the compact variant fits). `columns`, `interval` (ms the camera rests; 0 holds still), `order` (serial | shuffle), `zoom`, `intro` (ms the camera drifts with nothing in focus while the copy arrives; default 1800) and `onFocus` pass straight to the wall; `layout` is split (copy left, wall right), center (copy between two walls — the left camera resting high, the right low, taking turns, the second wall dealing the same items from halfway through; the tools sit under the copy; zoom defaults to 0.9; below lg a single band of every card sits between the copy and the tools), or center-background (`persistentBackground`); `showMobileWall` (boolean, default true: set to false to completely hide wall animations on small screens <lg across all layouts); `linkComponent` takes next/link. The headline uses your theme's font-serif, the command font-mono; colours are theme tokens.",
  "categories": [
    "marketing",
    "layout"
  ],
  "type": "registry:component"
}
