Integration Hub

Brand hub with a pyramid of connected apps and a looping pulse that travels down each branch.

Add to your project

Pick the way you like to build.

Let your AI assistant add it

Copy this prompt and paste it into the AI tool building your site.

# Integrate this UI component

Add this component to my project.

1. Install any missing dependencies.
2. Make sure Tailwind CSS is set up and scans the new file.
3. Prefer passing props over editing the component source — changing the component itself may break it.
4. Adjust colors, size, and layout via props or wrapper classes to match my project. You know my codebase better than this snippet does.

## Component source

```tsx
'use client';

import { motion } from 'framer-motion';
import { useId, useState, type CSSProperties } from 'react';

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// hub           Central brand node — name, optional logo URL, brand color
// items         Connected apps arranged in a pyramid under the hub
// accentColor   Stroke / pulse color for the connection lines
// background    Section background (transparent by default)
// className     Styles for the outer shell
//
type HubNode = {
  name: string;
  logo?: string;
  color?: string;
};

type IntegrationItem = {
  name: string;
  logo?: string;
  color?: string;
  /** When false, the card renders but gets no connection line. Default true. */
  connected?: boolean;
};

type IntegrationHubProps = {
  hub?: HubNode;
  items?: IntegrationItem[];
  accentColor?: string;
  background?: string;
  className?: string;
};

// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const ASSET = 'https://uniquel.io/images';

const DEFAULT_HUB: HubNode = {
  name: 'Uniquel',
  logo: `${ASSET}/uniquel-icon.png`,
  color: '#FFFFFF',
};

const DEFAULT_ITEMS: IntegrationItem[] = [
  { name: 'Cursor', logo: `${ASSET}/works-with/cursor.png`, color: '#000000' },
  { name: 'Claude', logo: `${ASSET}/works-with/claude.png`, color: '#D97757' },
  { name: 'Figma', logo: `${ASSET}/works-with/figma.png`, color: '#F24E1E' },
  { name: 'v0', logo: `${ASSET}/works-with/v0.png`, color: '#000000' },
  { name: 'Lovable', logo: `${ASSET}/works-with/lovable.png`, color: '#FF6B6B' },
  { name: 'Replit', logo: `${ASSET}/works-with/replit.png`, color: '#F26207' },
  { name: 'Codex', logo: `${ASSET}/works-with/codex.png`, color: '#000000' },
  { name: 'Base44', logo: `${ASSET}/works-with/base44.png`, color: '#6366F1' },
  { name: 'Windsurf', logo: `${ASSET}/works-with/windsurf.png`, color: '#0EA5E9' },
];

const HUB_SIZE = 84;
const ICON_SIZE = 52;
const ICON_GAP = 22;
const ROW_GAP = 70;
const PAD = 22;
const PAD_X = Math.round(PAD * 1.15);
const PAD_TOP = Math.round(PAD * 0.9);
const PAD_BOTTOM = Math.round(PAD * 1.75);
const HUB_RENDER_LIFT = 20;
const PULSE_MS = 2.4;

type LayoutNode = { item: IntegrationItem; x: number; y: number; i: number; row: number };

function rowCounts(n: number): number[] {
  if (n <= 0) return [];
  if (n === 9) return [2, 3, 4];
  const rows: number[] = [];
  let left = n;
  let size = Math.min(2, n);
  while (left > 0) {
    const take = Math.min(size, left);
    rows.push(take);
    left -= take;
    size += 1;
  }
  return rows;
}

function stageSize(itemCount: number) {
  const rows = rowCounts(itemCount);
  const widest = Math.max(1, ...rows);
  const contentW = widest * ICON_SIZE + Math.max(0, widest - 1) * ICON_GAP;
  const width = contentW + PAD_X * 2;
  const hubAnchorY = PAD_TOP + HUB_SIZE / 2;
  const hubRenderY = hubAnchorY - HUB_RENDER_LIFT;
  const rowStart = hubAnchorY + HUB_SIZE / 2 + 32;
  const lastY = rowStart + Math.max(0, rows.length - 1) * ROW_GAP;
  const contentHeight = lastY + ICON_SIZE / 2 + PAD_BOTTOM;
  const height = Math.max(contentHeight, hubRenderY + HUB_SIZE / 2 + PAD_TOP);
  return { width, height, hubX: width / 2, hubRenderY, rowStart };
}

function layoutItems(
  items: IntegrationItem[],
  width: number,
  rowStart: number
): LayoutNode[] {
  const rows = rowCounts(items.length);
  const nodes: LayoutNode[] = [];
  let index = 0;

  rows.forEach((count, rowIndex) => {
    const y = rowStart + rowIndex * ROW_GAP;
    const span = count * ICON_SIZE + (count - 1) * ICON_GAP;
    const startX = (width - span) / 2 + ICON_SIZE / 2;
    for (let c = 0; c < count; c++) {
      nodes.push({
        item: items[index],
        x: startX + c * (ICON_SIZE + ICON_GAP),
        y,
        i: index,
        row: rowIndex,
      });
      index += 1;
    }
  });

  return nodes;
}

function linkPath(fromX: number, fromY: number, toX: number, toY: number) {
  const midY = fromY + (toY - fromY) * 0.5;
  return `M ${fromX} ${fromY} C ${fromX} ${midY}, ${toX} ${midY}, ${toX} ${toY}`;
}

/** Parent→child edges between adjacent rows; x-order preserved so paths never cross. */
function buildEdges(
  nodes: LayoutNode[],
  hubX: number,
  hubRenderY: number
) {
  const byRow = new Map<number, LayoutNode[]>();
  nodes.forEach((n) => {
    const list = byRow.get(n.row) ?? [];
    list.push(n);
    byRow.set(n.row, list);
  });

  const edges: { fromX: number; fromY: number; toX: number; toY: number; key: string }[] = [];
  const rowIndexes = [...byRow.keys()].sort((a, b) => a - b);
  if (!rowIndexes.length) return edges;

  const wired = (n: LayoutNode) => n.item.connected !== false;
  const hubBottomY = hubRenderY + HUB_SIZE / 2 - 4;

  byRow.get(rowIndexes[0])!.filter(wired).forEach((child) => {
    edges.push({
      fromX: hubX,
      fromY: hubBottomY,
      toX: child.x,
      toY: child.y - ICON_SIZE / 2,
      key: `hub-${child.i}`,
    });
  });

  for (let r = 0; r < rowIndexes.length - 1; r++) {
    const parents = byRow.get(rowIndexes[r])!.filter(wired);
    const children = byRow.get(rowIndexes[r + 1])!.filter(wired);
    if (!parents.length || !children.length) continue;

    children.forEach((child, ci) => {
      const pi =
        children.length === 1
          ? Math.floor(parents.length / 2)
          : Math.round((ci * (parents.length - 1)) / (children.length - 1));
      const parent = parents[pi];
      edges.push({
        fromX: parent.x,
        fromY: parent.y + ICON_SIZE / 2,
        toX: child.x,
        toY: child.y - ICON_SIZE / 2,
        key: `${parent.i}-${child.i}`,
      });
    });
  }

  return edges;
}

function NodeCard({
  name,
  logo,
  color,
  size,
}: {
  name: string;
  logo?: string;
  color?: string;
  size: number;
}) {
  const [failed, setFailed] = useState(false);
  const initial = name.slice(0, 1).toUpperCase();
  const showLogo = Boolean(logo) && !failed;

  return (
    <div
      className='relative flex items-center justify-center rounded-2xl bg-white shadow-[0_8px_24px_rgba(0,0,0,0.28)]'
      style={{ width: size, height: size }}
      title={name}
    >
      {showLogo ? (
        <img
          src={logo}
          alt={name}
          draggable={false}
          onError={() => setFailed(true)}
          className='h-[52%] w-[52%] object-contain'
        />
      ) : (
        <span
          className='flex h-[58%] w-[58%] items-center justify-center rounded-xl text-sm font-bold text-white'
          style={{ backgroundColor: color || '#64748B' }}
        >
          {initial}
        </span>
      )}
    </div>
  );
}

export default function IntegrationHub({
  hub = DEFAULT_HUB,
  items = DEFAULT_ITEMS,
  accentColor = '#4BC4BE',
  background = 'transparent',
  className = '',
}: IntegrationHubProps) {
  const uid = useId().replace(/:/g, '');
  const deck = items.length ? items : DEFAULT_ITEMS;
  const { width, height, hubX, hubRenderY, rowStart } = stageSize(deck.length);
  const nodes = layoutItems(deck, width, rowStart);
  const edges = buildEdges(nodes, hubX, hubRenderY);
  const hubColor = hub.color || DEFAULT_HUB.color || '#FFFFFF';

  const rootStyle: CSSProperties = {
    background,
    width: '100%',
  };

  return (
    <div
      className={['relative flex w-full items-center justify-center overflow-visible', className]
        .filter(Boolean)
        .join(' ')}
      style={rootStyle}
      role='img'
      aria-label={`${hub.name} connected to ${deck.map((d) => d.name).join(', ')}`}
    >
      <div className='relative' style={{ width, height }}>
        <svg
          aria-hidden
          className='pointer-events-none absolute inset-0 h-full w-full'
          viewBox={`0 0 ${width} ${height}`}
          fill='none'
        >
          <defs>
            <filter id={`glow-${uid}`} x='-50%' y='-50%' width='200%' height='200%'>
              <feGaussianBlur stdDeviation='2.5' result='blur' />
              <feMerge>
                <feMergeNode in='blur' />
                <feMergeNode in='SourceGraphic' />
              </feMerge>
            </filter>
            <linearGradient id={`pulse-${uid}`} x1='0%' y1='0%' x2='0%' y2='100%'>
              <stop offset='0%' stopColor={accentColor} stopOpacity='0' />
              <stop offset='40%' stopColor={accentColor} stopOpacity='1' />
              <stop offset='100%' stopColor={accentColor} stopOpacity='0.2' />
            </linearGradient>
          </defs>

          {edges.map((edge, ei) => {
            const d = linkPath(edge.fromX, edge.fromY, edge.toX, edge.toY);
            return (
              <g key={edge.key}>
                <path
                  d={d}
                  stroke={accentColor}
                  strokeOpacity={0.22}
                  strokeWidth={1.25}
                  strokeLinecap='round'
                />
                <motion.path
                  d={d}
                  stroke={`url(#pulse-${uid})`}
                  strokeWidth={2}
                  strokeLinecap='round'
                  filter={`url(#glow-${uid})`}
                  strokeDasharray='28 100'
                  animate={{ strokeDashoffset: [128, 0] }}
                  transition={{
                    duration: PULSE_MS,
                    repeat: Number.POSITIVE_INFINITY,
                    ease: 'linear',
                    delay: (ei % 3) * 0.18,
                  }}
                />
                <motion.circle
                  cx={edge.toX}
                  cy={edge.toY}
                  r={3}
                  fill={accentColor}
                  animate={{ opacity: [0.25, 1, 0.25], scale: [0.85, 1.15, 0.85] }}
                  transition={{
                    duration: PULSE_MS,
                    repeat: Number.POSITIVE_INFINITY,
                    ease: 'easeInOut',
                    delay: (ei % 3) * 0.18 + PULSE_MS * 0.72,
                  }}
                />
              </g>
            );
          })}
        </svg>

        {/* Hub */}
        <div
          className='absolute z-10 -translate-x-1/2 -translate-y-1/2'
          style={{ left: hubX, top: hubRenderY }}
        >
          <div
            className='flex items-center justify-center rounded-2xl bg-white shadow-[0_12px_32px_rgba(0,0,0,0.12)]'
            style={{
              width: HUB_SIZE,
              height: HUB_SIZE,
              backgroundColor: hubColor,
              boxShadow: `0 0 0 1px ${accentColor}66, 0 0 28px ${accentColor}40, 0 12px 32px rgba(0,0,0,0.12)`,
            }}
          >
            {hub.logo ? (
              <img
                src={hub.logo}
                alt={hub.name}
                draggable={false}
                className='h-[62%] w-[62%] object-contain'
              />
            ) : (
              <span
                className='select-none text-3xl font-black tracking-tight'
                style={{ color: '#27265D' }}
              >
                {hub.name.slice(0, 1)}
              </span>
            )}
          </div>
        </div>

        {/* Connected icons */}
        {nodes.map((node) => (
          <div
            key={`node-${node.i}`}
            className='absolute z-10 -translate-x-1/2 -translate-y-1/2'
            style={{ left: node.x, top: node.y }}
          >
            <NodeCard
              name={node.item.name}
              logo={node.item.logo}
              color={node.item.color}
              size={ICON_SIZE}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

```

Works with your AI tools

ClaudeClaude*
CodexCodex*
LovableLovable
Base44Base44
Figma MakeFigma Make
ReplitReplit*
CursorCursor*
v0v0

* Compatible with most projects that use the default React/Next.js framework.

Built With

ReactTailwind CSSFramer Motion

Props

PropTypeDescription
hubHubNodeCentral brand node — name, optional logo URL, brand color
itemsIntegrationItem[]Connected apps arranged in a pyramid under the hub
accentColorstringStroke / pulse color for the connection lines
backgroundstringSection background (transparent by default)
classNamestringStyles for the outer shell