Aaditya

Embedding Map

Component 1

About

A 2D map of an embedding space: clusters, a retrieval query and its nearest chunks linked and ranked. Pan, zoom, hover for the chunk, click to pin it.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Embedding Map to your project and fits it to your theme.

Code

How Embedding Map is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Toggle } from "@base-ui/react/toggle"
import { ToggleGroup } from "@base-ui/react/toggle-group"
import { EmbeddingCluster, EmbeddingMap, EmbeddingNeighbor, EmbeddingPoint, EmbeddingQuery } from "@/components/ui/embedding-map"

const reducedMotion = () =>
  typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches

/**
 * Steps through `values` on a timer, so a 21st preview (and its cover video) shows every option
 * without a click. Picking a value stops the tour. Reduced motion never starts it.
 */
function useCycle<T>(values: readonly T[], ms: number) {
  const [index, setIndex] = React.useState(0)
  const [held, setHeld] = React.useState(false)
  React.useEffect(() => {
    if (held || reducedMotion()) return
    const id = window.setInterval(() => setIndex((n) => (n + 1) % values.length), ms)
    return () => window.clearInterval(id)
  }, [held, values.length, ms])
  const pick = React.useCallback(
    (value: T) => {
      setHeld(true)
      setIndex(Math.max(0, values.indexOf(value)))
    },
    [values],
  )
  return [values[index] as T, pick] as const
}

const refundQuery: EmbeddingQuery = {
  text: "Can a customer still get a refund on a card payment from seven months ago?",
  x: -1.45,
  y: 1.75,
}

/**
 * What a retriever hands back: the closest chunks by similarity, which in a 2D projection are
 * near the query but not always the nearest dots. Skips repeated passages.
 */
function nearestTo(
  points: EmbeddingPoint[],
  q: { x: number; y: number },
  k = 8,
): EmbeddingNeighbor[] {
  const seen = new Set<string>()
  return points
    .map((p) => ({ p, d: Math.hypot(p.x - q.x, p.y - q.y) }))
    .sort((a, b) => a.d - b.d)
    .filter(({ p }) => {
      const key = p.text ?? p.id
      if (seen.has(key)) return false
      seen.add(key)
      return true
    })
    .slice(0, k)
    .map(({ p, d }) => ({ id: p.id, score: Math.max(0.3, 0.9 - d * 0.22) }))
}

/** A small seeded random source, so the map looks the same on every load. */
function rng(seed: number) {
  let s = seed >>> 0
  return () => {
    s = (s + 0x6d2b79f5) >>> 0
    let t = s
    t = Math.imul(t ^ (t >>> 15), t | 1)
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

const plan: Record<
  string,
  { cx: number; cy: number; spread: number; sources: string[]; texts: string[] }
> = {
  refunds: {
    cx: -2.4,
    cy: 1.2,
    spread: 0.55,
    sources: ["refund-policy-2026.pdf", "refunds-and-disputes.md", "api-reference-v4.html"],
    texts: [
      "Cards accept refunds up to 180 days after capture. After that the network rejects the refund and the merchant sees refund_window_closed.",
      "Refunds go back to the original payment method. Card refunds usually land in 5 to 10 business days.",
      "Partial refunds can be issued more than once until the captured amount is used up.",
      "SEPA debits can be refunded for 13 months, but iDEAL and Bancontact payments must be refunded within 90 days.",
      "Processing fees are not returned when a payment is refunded, except full refunds within 7 days on the volume plan.",
      "Currency conversion is reversed at the rate on the day of the refund, not the day of the payment.",
    ],
  },
  disputes: {
    cx: -0.2,
    cy: 2.6,
    spread: 0.5,
    sources: ["chargeback-playbook.pdf", "refunds-and-disputes.md", "chargeback-playbook.pdf"],
    texts: [
      "The merchant has 7 days to respond with evidence. Good evidence is specific: tracking, IP address and signed terms.",
      "A lost dispute becomes a chargeback and counts toward the merchant's dispute rate.",
      "Monitoring programs start at a 0.9% monthly dispute ratio.",
      "Agents can extend the dispute deadline by 48 hours once, when the merchant waits on a carrier.",
      "The bank pulls the funds right away and charges a dispute fee of 15.00, returned only if the merchant wins.",
    ],
  },
  payouts: {
    cx: 2.6,
    cy: 1.9,
    spread: 0.55,
    sources: [
      "payouts-schedule.md",
      "settlement-export-schema.json",
      "merchant-onboarding-checklist.docx",
    ],
    texts: [
      "Payouts run daily on a two business day rolling schedule once the account is verified.",
      "A negative balance is recovered from the next payout before anything is paid out.",
      "Instant payouts to debit cards cost 1% with a minimum of 0.50.",
      "Settlement exports list every balance transaction with its payout id and fee breakdown.",
    ],
  },
  api: {
    cx: 1.0,
    cy: -1.9,
    spread: 0.5,
    sources: [
      "api-reference-v4.html",
      "api-reference-v4.html",
      "incident-2026-03-18-webhook-delays.md",
    ],
    texts: [
      "Idempotency keys expire after 24 hours. Reusing one with different parameters returns idempotency_mismatch.",
      "Rate limits are 100 read and 100 write requests per second in live mode.",
      "card_declined carries a decline_code from the issuer; do_not_honor means the customer must call their bank.",
      "Requests time out after 80 seconds. Retry with the same idempotency key.",
    ],
  },
  onboarding: {
    cx: -2.0,
    cy: -1.6,
    spread: 0.5,
    sources: ["merchant-onboarding-checklist.docx", "merchant-onboarding-checklist.docx"],
    texts: [
      "Set a statement descriptor customers will recognize to avoid friendly fraud disputes.",
      "Verification needs a government ID for every owner above 25% and a recent bank statement.",
      "Test mode keys start with sk_test; switch to live keys only after the checklist is complete.",
    ],
  },
  webhooks: {
    cx: 3.3,
    cy: -0.6,
    spread: 0.45,
    sources: ["incident-2026-03-18-webhook-delays.md", "api-reference-v4.html"],
    texts: [
      "Webhooks retry with exponential backoff for up to 3 days. Return a 2xx within 10 seconds.",
      "Dispute notifications were delayed by up to 9 hours on 18 March; affected deadlines were extended.",
      "Verify every event with the signing secret before trusting its payload.",
    ],
  },
}

/** Clustered points for a support corpus, `perCluster` chunks around each center. */
function makeSupportPoints(perCluster = 70, seed = 7): EmbeddingPoint[] {
  const rand = rng(seed)
  const gauss = () => {
    const u = Math.max(1e-9, rand())
    const v = rand()
    return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)
  }
  const out: EmbeddingPoint[] = []
  for (const [cluster, c] of Object.entries(plan)) {
    const n = Math.round(perCluster * (0.7 + rand() * 0.6))
    for (let i = 0; i < n; i++) {
      out.push({
        id: `${cluster}-${i}`,
        cluster,
        x: c.cx + gauss() * c.spread,
        y: c.cy + gauss() * c.spread * 0.8,
        source: c.sources[i % c.sources.length],
        text: c.texts[i % c.texts.length],
      })
    }
  }
  return out
}

const supportPoints = makeSupportPoints()

const refundNeighbors = nearestTo(supportPoints, refundQuery)

const deadlineQuery = {
  text: "Why did merchants miss dispute deadlines during the webhook delay?",
  x: 1.3,
  y: 0.9,
}

const QUERIES = {
  refund: { query: refundQuery, neighbors: refundNeighbors },
  deadline: { query: deadlineQuery, neighbors: nearestTo(supportPoints, deadlineQuery) },
}

const OPTIONS = [
  { value: "refund", label: "Refund window" },
  { value: "deadline", label: "Missed deadlines" },
] as const

const VALUES = OPTIONS.map((o) => o.value)

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

/** A labeled segmented control on Base UI ToggleGroup. One value is always selected. */
function Segmented<T extends string>({
  label,
  value,
  onValueChange,
  options,
  className,
}: {
  label: string
  value: T
  onValueChange: (value: T) => void
  options: readonly { value: T; label: string }[]
  className?: string
}) {
  return (
    <div className={cn("flex max-w-full min-w-0 flex-col gap-1.5", className)}>
      <span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) font-medium">{label}</span>
      <ToggleGroup
        value={[value]}
        onValueChange={(next) => {
          const picked = next[0] as T | undefined
          if (picked) onValueChange(picked)
        }}
        aria-label={label}
        className="bg-(color:--chatcn-fill) rounded-(--chatcn-radius-md) chatcn-scroll flex h-8 w-fit max-w-full items-center gap-0.5 overflow-x-auto p-0.5 pointer-coarse:h-11"
      >
        {options.map((option) => (
          <Toggle
            key={option.value}
            value={option.value}
            className={cn(
              "chatcn-focus rounded-(--chatcn-radius-sm) text-[0.8125rem]/[1.25rem] inline-flex h-full shrink-0 items-center px-2.5 font-medium whitespace-nowrap",
              "text-(color:--chatcn-fg-subtle) ease-(--chatcn-ease-standard) transition-[color,background-color,box-shadow] duration-150",
              "hover:text-(color:--chatcn-fg) data-[pressed]:bg-(color:--chatcn-card) data-[pressed]:text-(color:--chatcn-fg) data-[pressed]:shadow-(--chatcn-shadow-xs)",
            )}
          >
            {option.label}
          </Toggle>
        ))}
      </ToggleGroup>
    </div>
  )
}

const supportClusters: EmbeddingCluster[] = [
  { id: "refunds", label: "Refunds", color: 1 },
  { id: "disputes", label: "Disputes", color: 2 },
  { id: "payouts", label: "Payouts", color: 3 },
  { id: "api", label: "API errors", color: 4 },
  { id: "onboarding", label: "Onboarding", color: 5 },
  { id: "webhooks", label: "Webhooks", color: 6 },
]

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/**
 * A support corpus in six clusters with a retrieval query on top. The query switches every few
 * seconds and the nearest chunks re-link.
 */
export default function EmbeddingMapPreview() {
  const [which, setWhich] = useCycle<keyof typeof QUERIES>(VALUES, 4000)
  const { query, neighbors } = QUERIES[which]
  return (
    <FitFrame minWidth="56rem">
      <div className="flex w-full flex-col items-center justify-center gap-6 p-6">
        <div className="flex w-full max-w-4xl flex-col gap-3">
          <div data-fit-hide>
            <Segmented label="Query" value={which} onValueChange={setWhich} options={OPTIONS} />
          </div>
          <EmbeddingMap
            points={supportPoints}
            clusters={supportClusters}
            query={query}
            neighbors={neighbors}
            method="UMAP, 15 neighbors"
          />
        </div>
        <MoreOn slug="embedding-map">Pan, zoom and inspect every chunk on</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aaditya

View all
  • Pixel Orb

    Pixel Orb

    9
  • Dither Orb

    Dither Orb

    9
  • Dot Matrix Loader

    Dot Matrix Loader

    8
  • Halftone Loader

    Halftone Loader

    6
  • ASCII 3D

    ASCII 3D

    5
  • Wireframe Globe

    Wireframe Globe

    5
  • Audio Visualizer

    Audio Visualizer

    5
  • LED Matrix

    LED Matrix

    4

Explore more

Part of chatcn — browse the full library on 21st.dev.