Browse components
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
* Compatible with most projects that use the default React/Next.js framework.
Built With
Props
| Prop | Type | Description |
|---|---|---|
hub | HubNode | Central brand node — name, optional logo URL, brand color |
items | IntegrationItem[] | Connected apps arranged in a pyramid under the hub |
accentColor | string | Stroke / pulse color for the connection lines |
background | string | Section background (transparent by default) |
className | string | Styles for the outer shell |