{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tech-stack",
  "title": "Tech Stack",
  "author": "AlgorizeTech",
  "description": "The tools a product is built with: a label, then one group of marks at a time in two rows — an odd group as three over four — each mark blurring out and the next group blurring in. Use it as the \"Built with\" line in a hero or footer.",
  "dependencies": [],
  "registryDependencies": [
    "https://avaraui.com/r/icons.json",
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/TechStackBlock.tsx",
      "content": "\"use client\";\n\nimport React, {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// ==========================================\n// TECH STACK\n// The tools a product is built with: a label, then one group of marks at a\n// time in two rows — an odd group staggers as three over four. Every\n// `interval` ms the group gives way to the next, each mark blurring out and\n// the next blurring in, a beat after the one before it. It turns only while\n// it is on screen; with reduced motion the swap is a cut.\n//\n//   <TechStack label=\"Built with\">\n//     <TechStackGroup>\n//       <TechStackTool name=\"React\" icon={<ReactIcon />} />\n//       …\n//     </TechStackGroup>\n//     <TechStackGroup>…</TechStackGroup>\n//   </TechStack>\n// ==========================================\n\nexport type TechStackVariant = \"labelled\" | \"compact\";\n\nexport interface TechStackProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The line over the marks. */\n  label?: React.ReactNode;\n  /** Milliseconds each group stays; `0` holds the first. */\n  interval?: number;\n  /**\n   * `labelled`: the label on the left, names beside the marks from `xl` up.\n   * `compact`: the label centred between two hairlines and marks only, a\n   * touch larger — each name is its tooltip and is read to screen readers.\n   */\n  variant?: TechStackVariant;\n}\n\nexport interface TechStackToolProps extends React.LiHTMLAttributes<HTMLLIElement> {\n  name: string;\n  /** Any node — an inline SVG or an `<img>`. */\n  icon: React.ReactNode;\n}\n\n// The same blur as a headline word: in from below, out upwards. `--ts-i` is\n// each mark's place in its group.\nconst STYLES = `\n@keyframes ts-tool-in { from { opacity: 0; transform: translateY(20px); filter: blur(8px); } }\n@keyframes ts-tool-out { to { opacity: 0; transform: translateY(-20px); filter: blur(8px); } }\n.ts-enter > li { animation: ts-tool-in 0.6s cubic-bezier(0, 0, 0.58, 1) calc(var(--ts-i) * 0.05s) backwards; }\n.ts-leave > li { animation: ts-tool-out 0.4s cubic-bezier(0.42, 0, 1, 1) calc(var(--ts-i) * 0.03s) forwards; }\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/** Whether the element is on screen now — so the groups never turn unseen. */\nfunction useOnScreen<T extends Element>() {\n  const ref = useRef<T>(null);\n  const [onScreen, setOnScreen] = useState(false);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([entry]) => setOnScreen(!!entry?.isIntersecting));\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n  return [ref, onScreen] as const;\n}\n\nfunction mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\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 group 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\nconst VariantContext = createContext<TechStackVariant>(\"labelled\");\n\n/** How the root is showing one group: entering, leaving, or at rest. */\ninterface LayerState {\n  ref: (node: HTMLElement | null) => void;\n  className?: string;\n  style?: React.CSSProperties;\n  onAnimationEnd?: React.AnimationEventHandler<HTMLUListElement>;\n}\nconst LayerContext = createContext<LayerState | null>(null);\n\nconst ToolContext = createContext<{ index: number; count: number }>({ index: 0, count: 0 });\n\n// ------------------------------------------\n// Root\n// ------------------------------------------\nexport const TechStack = forwardRef<HTMLDivElement, TechStackProps>(\n  ({ label, interval = 3600, variant = \"labelled\", className, children, ...props }, ref) => {\n    const groups = React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement<{\n      children?: React.ReactNode;\n    }>[];\n    const reduceMotion = useReducedMotion();\n    const [rootRef, onScreen] = useOnScreen<HTMLDivElement>();\n\n    const [index, setIndex] = useState(0);\n    const groupCount = groups.length;\n    useEffect(() => {\n      if (groupCount < 2 || interval <= 0 || !onScreen) return;\n      const t = window.setInterval(() => setIndex((i) => (i + 1) % groupCount), interval);\n      return () => window.clearInterval(t);\n    }, [groupCount, interval, onScreen]);\n\n    const current = groupCount ? index % groupCount : 0;\n    const group = groups[current];\n    const toolCount = group ? React.Children.count(group.props.children) : 0;\n    // Each mark leaves 0.03s after the one before it, taking 0.4s.\n    const { layers, drop, register, valueOf } = useSwap(current, group, reduceMotion, 400 + 30 * toolCount);\n    const compact = variant === \"compact\";\n\n    return (\n      <VariantContext.Provider value={variant}>\n        <div\n          ref={mergeRefs(ref, rootRef)}\n          className={cn(\"flex w-full flex-col\", compact ? \"items-center gap-5\" : \"items-start gap-3\", className)}\n          {...props}\n        >\n          <style>{STYLES}</style>\n          {label &&\n            (compact ? (\n              <span className=\"flex w-full items-center gap-4 text-[13px] font-semibold leading-tight text-muted-foreground\">\n                <span className=\"h-px flex-1 bg-gradient-to-r from-transparent to-border\" aria-hidden />\n                {label}\n                <span className=\"h-px flex-1 bg-gradient-to-l from-transparent to-border\" aria-hidden />\n              </span>\n            ) : (\n              <span className=\"text-[13px] font-semibold leading-tight text-muted-foreground\">{label}</span>\n            ))}\n          <div className=\"relative grid w-full min-w-0\" aria-live=\"polite\">\n            {layers.map((layer) => (\n              <LayerContext.Provider\n                key={layer.id}\n                value={{\n                  ref: register(layer.id),\n                  className: layer.leaving ? \"ts-leave\" : layer.enter ? \"ts-enter\" : undefined,\n                  style: layer.pop,\n                  // Gone once the last mark has.\n                  onAnimationEnd: (e) => {\n                    if (layer.leaving && e.target === e.currentTarget.lastElementChild) drop(layer.id);\n                  },\n                }}\n              >\n                {valueOf(layer)}\n              </LayerContext.Provider>\n            ))}\n          </div>\n        </div>\n      </VariantContext.Provider>\n    );\n  },\n);\nTechStack.displayName = \"TechStack\";\n\n// ------------------------------------------\n// One group: two rows, two columns per tool. An odd group puts the smaller\n// row on top, starting one column in, so it sits between the tools below\n// (three over four); an even group lines up.\n// ------------------------------------------\nexport const TechStackGroup = forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\n  ({ className, style, children, onAnimationEnd, ...props }, ref) => {\n    const layer = useContext(LayerContext);\n    const tools = React.Children.toArray(children).filter(React.isValidElement);\n    return (\n      <ul\n        ref={mergeRefs(ref, layer?.ref)}\n        onAnimationEnd={(e) => {\n          layer?.onAnimationEnd?.(e);\n          onAnimationEnd?.(e);\n        }}\n        className={cn(\"col-start-1 row-start-1 grid gap-x-2 gap-y-[22px] sm:gap-x-3\", layer?.className, className)}\n        style={{ gridTemplateColumns: `repeat(${2 * Math.ceil(tools.length / 2)}, minmax(0, 1fr))`, ...style, ...layer?.style }}\n        {...props}\n      >\n        {tools.map((tool, index) => (\n          <ToolContext.Provider key={tool.key ?? index} value={{ index, count: tools.length }}>\n            {tool}\n          </ToolContext.Provider>\n        ))}\n      </ul>\n    );\n  },\n);\nTechStackGroup.displayName = \"TechStackGroup\";\n\n// ------------------------------------------\n// One tool: its mark, then its name.\n// ------------------------------------------\nexport const TechStackTool = forwardRef<HTMLLIElement, TechStackToolProps>(\n  ({ name, icon, className, style, ...props }, ref) => {\n    const compact = useContext(VariantContext) === \"compact\";\n    const { index, count } = useContext(ToolContext);\n    return (\n      <li\n        ref={ref}\n        title={compact ? name : undefined}\n        style={{ \"--ts-i\": index, ...style } as React.CSSProperties}\n        className={cn(\n          \"col-span-2 flex min-w-0 items-center justify-center gap-2.5 text-foreground [&>img]:shrink-0 [&>img]:object-contain [&>svg]:shrink-0\",\n          compact\n            ? \"[&>img]:size-7 [&>svg]:size-6\"\n            : \"[&>img]:size-8 xl:[&>img]:size-6 [&>svg]:size-7 xl:[&>svg]:size-5\",\n          index === 0 && count % 2 === 1 && \"col-start-2\",\n          className,\n        )}\n        {...props}\n      >\n        {icon}\n        <span className={cn(\"truncate text-[13px] font-medium\", compact ? \"sr-only\" : \"hidden xl:inline\")}>{name}</span>\n      </li>\n    );\n  },\n);\nTechStackTool.displayName = \"TechStackTool\";\n\nexport default TechStack;\n",
      "type": "registry:component",
      "target": "components/custom/blocks/TechStackBlock.tsx"
    }
  ],
  "docs": "Named exports: TechStack, TechStackGroup and TechStackTool. Compose it: <TechStack label interval variant> holds <TechStackGroup> children, each holding <TechStackTool name icon /> (icon is any node — an inline SVG or an <img>). One group shows at a time and gives way to the next every `interval` ms (default 3600; 0 holds the first), only while on screen; with prefers-reduced-motion the swap is a cut. `variant` is labelled (label on the left, names beside marks from xl up) or compact (label centred between hairlines, marks only, names as tooltips and screen-reader text). `label` takes any node. Four to eight tools per group read well. Colours are theme tokens.",
  "categories": [
    "marketing",
    "data-display"
  ],
  "type": "registry:component"
}
