{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"integrations-section","type":"registry:block","title":"Integrations Section","description":"Integrations showcase pairing a hub-and-spoke orbit (your logo at the center, integration icons on counter-rotating rings that keep icons upright and pause on hover) with a searchable card grid filtered by category chips behind a gliding pill, where cards enter, leave and reflow with spring layout animation.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/integrations-section.tsx","type":"registry:component","target":"@components/motion/integrations-section.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/integrations-section\n\nimport { ArrowUpRight, Search, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type CSSProperties, type ReactNode, useId, useMemo, useState } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Integration {\n  id: string;\n  name: string;\n  category: string;\n  description?: ReactNode;\n  /** Icon or logo node; sized to fit a small tile. */\n  icon: ReactNode;\n  href?: string;\n  /** Small label on the card, e.g. \"Beta\" or \"New\". */\n  badge?: ReactNode;\n  /** Extra text matched by search (aliases, keywords). */\n  keywords?: string[];\n}\n\nexport interface IntegrationsSectionProps {\n  integrations: Integration[];\n  /** The hub at the center of the orbit (your product's logo). */\n  centerLogo: ReactNode;\n  /** Accessible name for the hub. Default \"Our product\". */\n  centerLabel?: string;\n  /** Ids shown on the orbit, inner ring first. Defaults to the first 14 integrations. */\n  orbitIds?: string[];\n  /** Icons on the inner ring; the rest go on the outer ring. Default 5. */\n  innerCount?: number;\n  /** Seconds per inner-ring revolution; the outer ring is 1.5× slower and turns the other way. Default 40. */\n  speed?: number;\n  /** Category chips in order. Derived from `integrations` when omitted. */\n  categories?: string[];\n  /** Label of the chip that shows everything. Default \"All\". */\n  allLabel?: string;\n  /** Controlled category (\"\" or `allLabel` means all). */\n  category?: string;\n  defaultCategory?: string;\n  onCategoryChange?: (category: string) => void;\n  /** Show the search field. Default true. */\n  searchable?: boolean;\n  searchPlaceholder?: string;\n  eyebrow?: ReactNode;\n  heading?: ReactNode;\n  description?: ReactNode;\n  className?: string;\n  /** Classes for the scrolling card list (e.g. a max height). */\n  listClassName?: string;\n}\n\ninterface OrbitIcon {\n  integration: Integration;\n  angle: number;\n}\n\nfunction spread(items: Integration[], offset: number): OrbitIcon[] {\n  return items.map((integration, index) => ({\n    integration,\n    angle: offset + (360 / Math.max(1, items.length)) * index,\n  }));\n}\n\n/** Percent position of a point on a circle inscribed in its box. */\nfunction onCircle(angle: number): CSSProperties {\n  const rad = (angle * Math.PI) / 180;\n  // Rounded so server and client stringify the same value.\n  const round = (n: number) => Math.round(n * 1000) / 1000;\n  return {\n    left: `${round(50 + 50 * Math.cos(rad))}%`,\n    top: `${round(50 + 50 * Math.sin(rad))}%`,\n  };\n}\n\nfunction OrbitRing({\n  icons,\n  inset,\n  duration,\n  reverse,\n  animated,\n}: {\n  icons: OrbitIcon[];\n  inset: string;\n  duration: number;\n  reverse: boolean;\n  animated: boolean;\n}) {\n  // The whole HTML ring rotates (CSS `spin`); each icon runs the same spin\n  // in the opposite direction at the same duration, so it stays upright.\n  const spin: CSSProperties = animated\n    ? {\n        animationDuration: `${duration}s`,\n        animationDirection: reverse ? \"reverse\" : \"normal\",\n      }\n    : {};\n  const counter: CSSProperties = animated\n    ? {\n        animationDuration: `${duration}s`,\n        animationDirection: reverse ? \"normal\" : \"reverse\",\n      }\n    : {};\n  return (\n    <div\n      style={{ inset, ...spin }}\n      className={cn(\n        \"absolute rounded-full border border-dashed border-border-strong/70\",\n        animated && \"animate-spin group-hover/orbit:[animation-play-state:paused]\",\n      )}\n    >\n      {icons.map(({ integration, angle }) => (\n        <div\n          key={integration.id}\n          style={onCircle(angle)}\n          className=\"absolute size-0\"\n        >\n          <div\n            style={counter}\n            className={cn(\n              \"absolute -top-5 -left-5 grid size-10 place-items-center rounded-xl border border-border bg-card text-foreground shadow-sm [&_svg]:size-5\",\n              animated && \"animate-spin group-hover/orbit:[animation-play-state:paused]\",\n            )}\n            title={integration.name}\n          >\n            {integration.icon}\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction Orbit({\n  center,\n  centerLabel,\n  inner,\n  outer,\n  speed,\n  reduce,\n}: {\n  center: ReactNode;\n  centerLabel: string;\n  inner: OrbitIcon[];\n  outer: OrbitIcon[];\n  speed: number;\n  reduce: boolean | null;\n}) {\n  const animated = !reduce;\n  return (\n    <div\n      role=\"img\"\n      aria-label={`${centerLabel} connected to ${[...inner, ...outer]\n        .map((icon) => icon.integration.name)\n        .join(\", \")}`}\n      className=\"group/orbit relative mx-auto aspect-square w-full max-w-[19rem]\"\n    >\n      {/* Soft halo behind the hub. */}\n      <div\n        aria-hidden\n        className=\"absolute inset-[18%] rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--chart-1)_22%,transparent),transparent_70%)]\"\n      />\n      <div aria-hidden className=\"absolute inset-0\">\n        {outer.length ? (\n          <OrbitRing\n            icons={outer}\n            inset=\"5%\"\n            duration={speed * 1.5}\n            reverse\n            animated={animated}\n          />\n        ) : null}\n        {inner.length ? (\n          <OrbitRing\n            icons={inner}\n            inset=\"25%\"\n            duration={speed}\n            reverse={false}\n            animated={animated}\n          />\n        ) : null}\n        <div className=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2\">\n          {animated ? (\n            <motion.span\n              className=\"absolute inset-0 rounded-2xl bg-primary/25\"\n              animate={{ scale: [1, 1.6], opacity: [0.6, 0] }}\n              transition={{\n                duration: 2.4,\n                ease: EASE_OUT,\n                repeat: Number.POSITIVE_INFINITY,\n              }}\n            />\n          ) : null}\n          <div className=\"relative grid size-16 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-lg [&_svg]:size-7\">\n            {center}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction matches(integration: Integration, query: string) {\n  if (!query) return true;\n  const haystack = [\n    integration.name,\n    integration.category,\n    typeof integration.description === \"string\" ? integration.description : \"\",\n    ...(integration.keywords ?? []),\n  ]\n    .join(\" \")\n    .toLowerCase();\n  return query\n    .toLowerCase()\n    .split(/\\s+/)\n    .filter(Boolean)\n    .every((word) => haystack.includes(word));\n}\n\n/**\n * Integrations section: a hub-and-spoke orbit (your logo at the center,\n * integration icons riding one or two slowly counter-rotating rings, paused\n * on hover and still under reduced motion) beside a searchable grid of\n * integration cards. Category chips filter the grid with a gliding pill, and\n * cards enter, leave and reflow with layout animation.\n */\nexport function IntegrationsSection({\n  integrations,\n  centerLogo,\n  centerLabel = \"Our product\",\n  orbitIds,\n  innerCount = 5,\n  speed = 40,\n  categories: categoriesProp,\n  allLabel = \"All\",\n  category: categoryProp,\n  defaultCategory,\n  onCategoryChange,\n  searchable = true,\n  searchPlaceholder = \"Search integrations\",\n  eyebrow,\n  heading,\n  description,\n  className,\n  listClassName,\n}: IntegrationsSectionProps) {\n  const reduce = useReducedMotion();\n  const pillId = useId();\n  const searchId = useId();\n  const [query, setQuery] = useState(\"\");\n  const [internalCategory, setInternalCategory] = useState(\n    defaultCategory ?? allLabel,\n  );\n  const rawCategory = categoryProp ?? internalCategory;\n  const category = rawCategory === \"\" ? allLabel : rawCategory;\n\n  const categories = useMemo(() => {\n    if (categoriesProp) return categoriesProp;\n    const seen: string[] = [];\n    for (const item of integrations) {\n      if (!seen.includes(item.category)) seen.push(item.category);\n    }\n    return seen;\n  }, [categoriesProp, integrations]);\n\n  const { inner, outer } = useMemo(() => {\n    const picked = orbitIds\n      ? orbitIds\n          .map((id) => integrations.find((item) => item.id === id))\n          .filter((item): item is Integration => Boolean(item))\n      : integrations.slice(0, 14);\n    return {\n      inner: spread(picked.slice(0, innerCount), -90),\n      // Offset half a slot so outer icons sit between inner ones.\n      outer: spread(\n        picked.slice(innerCount),\n        -90 + 180 / Math.max(1, picked.length - innerCount),\n      ),\n    };\n  }, [integrations, orbitIds, innerCount]);\n\n  const visible = useMemo(\n    () =>\n      integrations.filter(\n        (item) =>\n          (category === allLabel || item.category === category) &&\n          matches(item, query.trim()),\n      ),\n    [integrations, category, allLabel, query],\n  );\n\n  const pick = (next: string) => {\n    if (next === category) return;\n    if (categoryProp === undefined) setInternalCategory(next);\n    onCategoryChange?.(next);\n  };\n\n  const header =\n    eyebrow || heading || description ? (\n      <div className=\"mx-auto mb-8 flex max-w-xl flex-col items-center gap-3 text-center\">\n        {eyebrow ? (\n          <p className=\"text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground\">\n            {eyebrow}\n          </p>\n        ) : null}\n        {heading ? (\n          <h2 className=\"text-balance font-display text-2xl font-semibold leading-tight tracking-tight text-foreground @3xl:text-3xl\">\n            {heading}\n          </h2>\n        ) : null}\n        {description ? (\n          <p className=\"text-pretty text-sm leading-6 text-muted-foreground\">\n            {description}\n          </p>\n        ) : null}\n      </div>\n    ) : null;\n\n  const chips = [allLabel, ...categories];\n\n  return (\n    <section className={cn(\"@container w-full\", className)}>\n      {header}\n      <div className=\"grid items-center gap-8 @xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)]\">\n        <Orbit\n          center={centerLogo}\n          centerLabel={centerLabel}\n          inner={inner}\n          outer={outer}\n          speed={speed}\n          reduce={reduce}\n        />\n\n        <div className=\"@container/list flex min-w-0 flex-col gap-3\">\n          {searchable ? (\n            <div className=\"relative\">\n              <label htmlFor={searchId} className=\"sr-only\">\n                {searchPlaceholder}\n              </label>\n              <Search\n                aria-hidden\n                className=\"pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground\"\n              />\n              <input\n                id={searchId}\n                type=\"search\"\n                value={query}\n                onChange={(event) => setQuery(event.target.value)}\n                placeholder={searchPlaceholder}\n                autoComplete=\"off\"\n                className=\"h-10 w-full rounded-full border border-border bg-card pr-10 pl-10 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-search-cancel-button]:hidden\"\n              />\n              {query ? (\n                <button\n                  type=\"button\"\n                  onClick={() => setQuery(\"\")}\n                  aria-label=\"Clear search\"\n                  className=\"absolute top-1/2 right-2 grid size-7 -translate-y-1/2 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50\"\n                >\n                  <X aria-hidden className=\"size-3.5\" />\n                </button>\n              ) : null}\n            </div>\n          ) : null}\n\n          <LayoutGroup id={pillId}>\n            <fieldset className=\"flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n              <legend className=\"sr-only\">Filter by category</legend>\n              {chips.map((chip) => {\n                const selected = chip === category;\n                return (\n                  <button\n                    key={chip}\n                    type=\"button\"\n                    aria-pressed={selected}\n                    onClick={() => pick(chip)}\n                    className={cn(\n                      \"relative h-8 shrink-0 rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50\",\n                      selected\n                        ? \"text-primary-foreground\"\n                        : \"text-muted-foreground hover:text-foreground\",\n                    )}\n                  >\n                    {selected ? (\n                      <motion.span\n                        layoutId=\"pill\"\n                        className=\"absolute inset-0 rounded-full bg-primary\"\n                        transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                      />\n                    ) : null}\n                    <span className=\"relative\">{chip}</span>\n                  </button>\n                );\n              })}\n            </fieldset>\n          </LayoutGroup>\n\n          <p aria-live=\"polite\" className=\"sr-only\">\n            {`${visible.length} integration${visible.length === 1 ? \"\" : \"s\"}`}\n          </p>\n\n          <div\n            className={cn(\n              \"max-h-80 overflow-y-auto overflow-x-hidden px-0.5 py-0.5 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\",\n              listClassName,\n            )}\n          >\n            {/* Layout animation is the point here: cards reflow as the\n                filter changes, so `layout` (position/size) is intended. */}\n            <motion.ul layout={!reduce} className=\"relative grid grid-cols-1 gap-2 @md/list:grid-cols-2\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                {visible.map((item) => (\n                  <motion.li\n                    key={item.id}\n                    layout={!reduce}\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 6 }}\n                    animate={{ opacity: 1, scale: 1, y: 0 }}\n                    exit={\n                      reduce\n                        ? { opacity: 0, transition: { duration: 0.12 } }\n                        : { opacity: 0, scale: 0.94, transition: { duration: 0.14, ease: EASE_OUT } }\n                    }\n                    transition={{\n                      layout: SPRING_LAYOUT,\n                      opacity: { duration: 0.22, ease: EASE_OUT },\n                      scale: SPRING_LAYOUT,\n                      y: SPRING_LAYOUT,\n                    }}\n                    className=\"group/card relative flex items-start gap-3 rounded-2xl border border-border bg-card p-3 transition-[border-color,box-shadow] duration-200 hover:border-border-strong hover:shadow-sm\"\n                  >\n                    <span className=\"grid size-9 shrink-0 place-items-center rounded-xl border border-border bg-background text-foreground [&_svg]:size-4.5\">\n                      {item.icon}\n                    </span>\n                    <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                      <span className=\"flex items-center gap-1.5\">\n                        {item.href ? (\n                          <a\n                            href={item.href}\n                            className=\"truncate text-sm font-medium text-foreground outline-none after:absolute after:inset-0 after:rounded-2xl focus-visible:after:ring-2 focus-visible:after:ring-ring/50\"\n                          >\n                            {item.name}\n                          </a>\n                        ) : (\n                          <span className=\"truncate text-sm font-medium text-foreground\">\n                            {item.name}\n                          </span>\n                        )}\n                        {item.badge ? (\n                          <span className=\"shrink-0 rounded-full bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary\">\n                            {item.badge}\n                          </span>\n                        ) : null}\n                        {item.href ? (\n                          <ArrowUpRight\n                            aria-hidden\n                            className=\"ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-hover/card:translate-x-0.5 group-hover/card:-translate-y-0.5 motion-reduce:transition-none\"\n                          />\n                        ) : null}\n                      </span>\n                      <span className=\"text-[11px] font-medium uppercase tracking-wider text-muted-foreground\">\n                        {item.category}\n                      </span>\n                      {item.description ? (\n                        <span className=\"line-clamp-2 text-xs leading-5 text-muted-foreground\">\n                          {item.description}\n                        </span>\n                      ) : null}\n                    </span>\n                  </motion.li>\n                ))}\n              </AnimatePresence>\n            </motion.ul>\n            {visible.length === 0 ? (\n              <motion.p\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{ duration: 0.2, ease: EASE_OUT }}\n                className=\"rounded-2xl border border-dashed border-border py-10 text-center text-sm text-muted-foreground\"\n              >\n                {query ? `No integrations match \"${query.trim()}\".` : \"No integrations in this category yet.\"}\n              </motion.p>\n            ) : null}\n          </div>\n        </div>\n      </div>\n    </section>\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"}]}