Chip
An inline, text-flowing token — used for mentions in the composer and reconstructed chips in messages.
Pulled results from web-search and a documentHover shows a preview panel for the referenced item. reference, with one deprecated flag.
"use client";
import { Chip } from "@intentface/chat/chip";
import { Globe } from "@keyline-icons/react";
import type { ReactElement, ReactNode } from "react";
// Chips flow inline with text. `variant` is an opaque string surfaced as
// data-variant, so the tinting rules are entirely yours.
export const Basic = () => (
<p className="max-w-md text-sm text-zinc-700 leading-8 dark:text-zinc-300">
Pulled results from{" "}
<Chip.Root variant="accent" className={CHIP_CLASS}>
<Chip.Icon className="flex items-center">
<Globe className="size-3.5" />
</Chip.Icon>
<Chip.Label>web-search</Chip.Label>
</Chip.Root>{" "}
and a{" "}
<Chip.Root className={CHIP_CLASS} renderWithPreview={renderWithPreview}>
<Chip.Label>document</Chip.Label>
<Chip.Preview>Hover shows a preview panel for the referenced item.</Chip.Preview>
</Chip.Root>{" "}
reference, with one{" "}
<Chip.Root variant="warning" className={CHIP_CLASS}>
<Chip.Label>deprecated</Chip.Label>
</Chip.Root>{" "}
flag.
</p>
);
const CHIP_CLASS =
"mx-0.5 inline-flex h-6 items-center gap-1 rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-2 align-middle font-medium text-xs text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] data-[variant=accent]:bg-[#0169cc]/10 data-[variant=accent]:bg-none data-[variant=accent]:text-[#0169cc] data-[variant=accent]:shadow-none data-[variant=warning]:bg-amber-500/12 data-[variant=warning]:bg-none data-[variant=warning]:text-amber-700 data-[variant=warning]:shadow-none dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:data-[variant=accent]:bg-[#4c9bea]/15 dark:data-[variant=accent]:text-[#4c9bea] dark:data-[variant=accent]:shadow-none dark:data-[variant=warning]:bg-amber-500/12 dark:data-[variant=warning]:text-amber-300 dark:data-[variant=warning]:shadow-none";
// Chip.Preview renders nothing on its own — Root hands you the badge and the
// preview content, and you compose whatever popup you want. This one is pure
// CSS so the demo needs no floating library.
const renderWithPreview = (badge: ReactElement, preview: ReactNode) => (
<span className="group relative inline-block">
{badge}
<span className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 w-52 -translate-x-1/2 rounded-xl bg-white p-2.5 text-xs text-zinc-500 leading-snug opacity-0 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)] transition-opacity group-hover:opacity-100 dark:bg-zinc-800 dark:text-zinc-400 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]">
{preview}
</span>
</span>
);Usage guidelines
- Inline token — flows with the surrounding text instead of breaking the line box.
- Two homes — backs the composer's mention decorations and the chips a message reconstructs from its wire format.
- Variants —
primary/accent/warningtint the surface. - Hover preview — a
Chip.Previewchild promotes the chip to a hover card and is never rendered inline. - Get started — see Quick start to add the package.
Anatomy
<Chip.Root variant="accent">
<Chip.Icon>{icon}</Chip.Icon>
<Chip.Label>{label}</Chip.Label>
</Chip.Root>With a hover preview — the Chip.Preview child is lifted into a hover card and
never rendered inline:
<Chip.Root>
<Chip.Label>{label}</Chip.Label>
<Chip.Preview>
<SourceCard source={source} />
</Chip.Preview>
</Chip.Root>Examples
Building the preview surface
Chip.Preview renders nothing itself. renderWithPreview hands you the badge
and the preview content and lets you decide what surface they go in, which is
what keeps a floating-UI dependency out of the package.
Whatever you build there inherits the hover-card obligations: it opens on
keyboard focus as well as hover, and Escape dismisses it. A CSS-only :hover
panel looks right and cannot be reached without a pointer.
Cited and . Tab to a chip, or hover it.
"use client";
import { Chip } from "@intentface/chat/chip";
import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
/*
* `Chip.Preview` renders nothing itself. `renderWithPreview` hands you the
* badge and the preview content and lets you decide what surface they go in,
* which is the seam that keeps the package free of a floating library.
*
* Whatever you build there inherits the hover-card obligations: it has to open
* on keyboard focus as well as hover, and Escape has to dismiss it. A CSS-only
* `:hover` panel looks right and is unreachable without a pointer.
*/
export const Preview = () => (
<p className="max-w-md text-sm text-zinc-700 leading-8 dark:text-zinc-300">
Cited{" "}
<Chip.Root className={chipClass} renderWithPreview={renderWithPreview}>
<Chip.Label>rfc-1149</Chip.Label>
<Chip.Preview>
<span className="font-medium text-zinc-900 dark:text-zinc-100">
A Standard for the Transmission of IP Datagrams on Avian Carriers
</span>
<span className="mt-1 block">Network Working Group, April 1990.</span>
</Chip.Preview>
</Chip.Root>{" "}
and{" "}
<Chip.Root variant="accent" className={chipClass} renderWithPreview={renderWithPreview}>
<Chip.Label>rfc-2324</Chip.Label>
<Chip.Preview>
<span className="font-medium text-zinc-900 dark:text-zinc-100">
Hyper Text Coffee Pot Control Protocol
</span>
<span className="mt-1 block">Network Working Group, April 1998.</span>
</Chip.Preview>
</Chip.Root>
. Tab to a chip, or hover it.
</p>
);
/**
* Hover and focus both open it, Escape and blur both close it, and the panel is
* `aria-hidden` while closed so it never reaches a screen reader out of turn.
* A real app would reach for a positioned hover card instead of hand-rolling
* this; the obligations are the same either way.
*/
const renderWithPreview = (badge: ReactElement, preview: ReactNode) => (
<PreviewSurface badge={badge} preview={preview} />
);
const PreviewSurface = ({ badge, preview }: { badge: ReactElement; preview: ReactNode }) => {
const [open, setOpen] = useState(false);
const host = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
return (
<span ref={host} className="relative inline-block">
{/* A real button, not a span with a tabIndex: this is the control that
discloses the preview, so it should be one. It carries the pointer and
focus handlers too, which keeps every listener on an element that can
actually receive them. */}
<button
type="button"
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="inline-flex cursor-pointer rounded-full align-middle focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2"
>
{badge}
</button>
<span
aria-hidden={!open}
className={[
"pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 w-56 -translate-x-1/2",
"rounded-xl bg-white p-2.5 text-left text-xs text-zinc-500 leading-snug shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)]",
"transition-opacity duration-150 dark:bg-zinc-800 dark:text-zinc-400 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]",
open ? "opacity-100" : "opacity-0",
].join(" ")}
>
{preview}
</span>
</span>
);
};
const chipClass =
"mx-0.5 inline-flex h-6 items-center gap-1 rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-2 align-middle font-medium text-xs text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] data-[variant=accent]:bg-[#0169cc]/10 data-[variant=accent]:bg-none data-[variant=accent]:text-[#0169cc] data-[variant=accent]:shadow-none dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:data-[variant=accent]:bg-[#4c9bea]/15 dark:data-[variant=accent]:text-[#4c9bea] dark:data-[variant=accent]:shadow-none";API reference
Every part accepts className, style, and render (see
Styling) and emits a bespoke part attribute (data-<part>) unless noted.
Chip
The inline token surface. Flows with the surrounding text rather than breaking
the line box, and lifts a Chip.Preview child into whatever surface
renderWithPreview builds. Renders a <span> element.
| Prop | Type | Default | Details |
|---|---|---|---|
variant | string | undefined | |
renderWithPreview | (badge: ReactElement, preview: ReactNode) => ReactNode | — |
| Attribute | Description |
|---|---|
data-chip | |
data-variant |
Chip.Icon
Leading inline icon, baseline-aligned to the label and aria-hidden, so
decoration stays out of the chip's accessible name. Renders a <span> element.
Chip.Label
The chip's text, which reads as part of the sentence around it. Renders a
<span> element.
Chip.Preview
Marker child whose content becomes the preview body. Renders nothing inline:
Chip.Root inspects its children and routes this through renderWithPreview,
so the package never owns a popup.
| Prop | Type | Default | Details |
|---|---|---|---|
children | ReactNode | (required) |