mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 22:18:39 +00:00
* feat(@shadcn/react): add message-scroller package Add the @shadcn/react headless primitives package with MessageScroller scroll anchoring, streaming follow, history prepend, and jump-to-message behavior. Includes geometry helpers, use-render utility, and unit, browser, and perf tests. * feat(registry): add chat components Add MessageScroller, Message, Bubble, Attachment, and Marker registry sources for base and radix, style variants, preview-03 chat blocks, and registry index wiring. * feat(v4): integrate chat components into docs site Wire chat components into the v4 app with docs routes, example preview pages, message part renderers, markdown support, registry build updates, and supporting lib utilities. * feat(examples): add chat component demos Add base and radix example demos for MessageScroller, Message, Bubble, Attachment, Marker, scroll-fade, and shimmer. * docs: add chat component documentation Add component and utility docs for the chat component set, update docs navigation, and add the June 2026 chat components changelog entry. * chore: regenerate registry JSON output Rebuild public registry artifacts for all style variants with the new chat components. * chore(release): add @shadcn/react publish and CI pipeline Add Changesets prerelease workflow, browser test job, RELEASING docs, and monorepo wiring for publishing @shadcn/react independently from the shadcn CLI. * docs: fix display of component preview on mobile * fix * fix * docs: add message scroller docs * style: format * fix
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { toast } from "sonner"
|
|
|
|
import {
|
|
Example,
|
|
ExampleWrapper,
|
|
} from "@/registry/bases/base/components/example"
|
|
import {
|
|
Bubble,
|
|
BubbleContent,
|
|
BubbleGroup,
|
|
BubbleReactions,
|
|
} from "@/registry/bases/base/ui/bubble"
|
|
import { Button } from "@/registry/bases/base/ui/button"
|
|
import {
|
|
Collapsible,
|
|
CollapsibleTrigger,
|
|
} from "@/registry/bases/base/ui/collapsible"
|
|
import { Marker, MarkerContent } from "@/registry/bases/base/ui/marker"
|
|
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
|
|
|
|
export default function BubbleExample() {
|
|
return (
|
|
<ExampleWrapper>
|
|
<BubbleSizes />
|
|
<BubbleVariants />
|
|
<BubbleAlignment />
|
|
<BubbleGrouped />
|
|
<BubbleCollapsible />
|
|
<BubbleButtonLinks />
|
|
<BubbleWithReactions />
|
|
<BubbleReactionsButtons />
|
|
</ExampleWrapper>
|
|
)
|
|
}
|
|
|
|
function BubbleVariants() {
|
|
return (
|
|
<Example title="Variants">
|
|
<div className="flex w-full max-w-md flex-col gap-8">
|
|
<Bubble>
|
|
<BubbleContent>
|
|
Default bubbles use the primary color for the active user side of a
|
|
chat.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>
|
|
Secondary bubbles are the standard neutral surface for assistant and
|
|
conversation content.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="muted">
|
|
<BubbleContent>
|
|
Muted bubbles lower the emphasis for quiet system notes or for
|
|
displaying supporting content.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="tinted" align="end">
|
|
<BubbleContent>
|
|
Tinted bubbles use a softer primary tint when primary fill is too
|
|
strong.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="outline">
|
|
<BubbleContent>
|
|
Outline bubbles can be used to frame message content and give it a
|
|
border.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="destructive">
|
|
<BubbleContent>
|
|
Destructive bubbles flag errors or failed actions in a conversation.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="ghost">
|
|
<BubbleContent>
|
|
<span className="whitespace-pre-wrap">
|
|
{`Ghost bubbles work for assistant text and other content that should not be framed.
|
|
|
|
This is perfect for assistant messages that should not have a frame and can take the full width of the container.
|
|
|
|
Ghost bubbles are full width and can take the full width of the container.
|
|
`}
|
|
</span>
|
|
</BubbleContent>
|
|
</Bubble>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
function BubbleSizes() {
|
|
return (
|
|
<Example title="Sizes">
|
|
<div className="flex w-full max-w-md flex-col gap-8">
|
|
<Bubble>
|
|
<BubbleContent>This is a one line bubble.</BubbleContent>
|
|
</Bubble>
|
|
<Bubble>
|
|
<BubbleContent>
|
|
This bubble has multiple lines. It should wrap to the next line and
|
|
you should see a different radius on the corners.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble>
|
|
<BubbleContent>
|
|
<p>This bubble has multiple lines.</p>
|
|
<p>
|
|
It should wrap to the next line and you should see a different
|
|
radius on the corners.
|
|
</p>
|
|
<p>Here is some more text to see how it wraps.</p>
|
|
</BubbleContent>
|
|
</Bubble>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
function BubbleGrouped() {
|
|
return (
|
|
<Example title="Grouped">
|
|
<div className="flex w-full max-w-md flex-col gap-8">
|
|
<BubbleGroup>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>I finished the audit pass.</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>
|
|
The registry output looks clean, but I found one stale route.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>Want me to remove it now?</BubbleContent>
|
|
</Bubble>
|
|
</BubbleGroup>
|
|
<BubbleGroup>
|
|
<Bubble variant="tinted" align="end">
|
|
<BubbleContent>Yes, clean that up.</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="tinted" align="end">
|
|
<BubbleContent>Then rerun the registry build.</BubbleContent>
|
|
</Bubble>
|
|
</BubbleGroup>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
const text = `The accessibility review found two focus states that were visually too subtle in dark mode.
|
|
|
|
I checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.
|
|
|
|
The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.
|
|
|
|
I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`
|
|
|
|
const previewLength = 180
|
|
|
|
function BubbleCollapsible() {
|
|
const [open, setOpen] = React.useState(false)
|
|
const isLong = text.length > previewLength
|
|
const preview = `${text.slice(0, previewLength)}...`
|
|
|
|
return (
|
|
<Example title="Collapsible">
|
|
<div className="flex w-full max-w-md flex-col gap-8">
|
|
<Collapsible open={open} onOpenChange={setOpen}>
|
|
<Bubble variant="muted" align="end">
|
|
<BubbleContent className="whitespace-pre-line">
|
|
<div>{open || !isLong ? text : preview}</div>
|
|
{isLong ? (
|
|
<CollapsibleTrigger
|
|
render={
|
|
<Button
|
|
variant="link"
|
|
className="gap-1 p-0 text-muted-foreground"
|
|
/>
|
|
}
|
|
>
|
|
{open ? "Show less" : "Show more"}
|
|
<IconPlaceholder
|
|
lucide="ChevronDownIcon"
|
|
tabler="IconChevronDown"
|
|
hugeicons="ArrowDown01Icon"
|
|
phosphor="CaretDownIcon"
|
|
remixicon="RiArrowDownSLine"
|
|
data-icon="inline-end"
|
|
className="group-data-panel-open/button:rotate-180"
|
|
/>
|
|
</CollapsibleTrigger>
|
|
) : null}
|
|
</BubbleContent>
|
|
</Bubble>
|
|
</Collapsible>
|
|
<Bubble variant="ghost">
|
|
<BubbleContent>
|
|
<span className="whitespace-pre-wrap">
|
|
{`Ghost bubbles work for assistant text and other content that should not be framed.
|
|
|
|
This is perfect for assistant messages that should not have a frame and can take the full width of the container.
|
|
|
|
Use this for content that needs the whole row.`}
|
|
</span>
|
|
</BubbleContent>
|
|
</Bubble>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
function BubbleWithReactions() {
|
|
return (
|
|
<Example title="Reaction Placement">
|
|
<div className="flex w-full max-w-md flex-col gap-12">
|
|
<Marker variant="separator">
|
|
<MarkerContent>side=bottom align=end</MarkerContent>
|
|
</Marker>
|
|
<Bubble>
|
|
<BubbleContent>This is a one line message.</BubbleContent>
|
|
<BubbleReactions
|
|
side="bottom"
|
|
align="end"
|
|
role="img"
|
|
aria-label="Reaction: thumbs up"
|
|
>
|
|
<span>👍</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Bubble variant="secondary" align="end">
|
|
<BubbleContent>
|
|
A longer message that wraps across lines so the reaction offset is
|
|
easier to inspect.
|
|
</BubbleContent>
|
|
<BubbleReactions
|
|
side="bottom"
|
|
align="start"
|
|
role="img"
|
|
aria-label="Reactions: thumbs up, surprised"
|
|
>
|
|
<span>👍</span>
|
|
<span>😮</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Bubble variant="tinted">
|
|
<BubbleContent>
|
|
A longer message that wraps across lines so the reaction offset is
|
|
easier to inspect.
|
|
</BubbleContent>
|
|
<BubbleReactions
|
|
side="bottom"
|
|
align="end"
|
|
role="img"
|
|
aria-label="Reactions: thumbs up, surprised, fire, eyes, and 8 more"
|
|
>
|
|
<span>👍</span>
|
|
<span>😮</span>
|
|
<span>🔥</span>
|
|
<span>👀</span>
|
|
<span>+8</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Marker variant="separator">
|
|
<MarkerContent>side=bottom align=start</MarkerContent>
|
|
</Marker>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>This is a one line message.</BubbleContent>
|
|
<BubbleReactions
|
|
side="bottom"
|
|
align="start"
|
|
role="img"
|
|
aria-label="Reaction: fire"
|
|
>
|
|
<span>🔥</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>
|
|
A longer message that wraps across lines so the reaction offset is
|
|
easier to inspect.
|
|
</BubbleContent>
|
|
<BubbleReactions
|
|
side="bottom"
|
|
align="start"
|
|
role="img"
|
|
aria-label="Reactions: thumbs up, surprised, fire, eyes"
|
|
>
|
|
<span>👍</span>
|
|
<span>😮</span>
|
|
<span>🔥</span>
|
|
<span>👀</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Marker variant="separator">
|
|
<MarkerContent>side=top align=start</MarkerContent>
|
|
</Marker>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>This is a one line message.</BubbleContent>
|
|
<BubbleReactions
|
|
side="top"
|
|
align="start"
|
|
role="img"
|
|
aria-label="Reaction: fire"
|
|
>
|
|
<span>🔥</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent>
|
|
A longer message that wraps across lines so the reaction offset is
|
|
easier to inspect.
|
|
</BubbleContent>
|
|
<BubbleReactions
|
|
side="top"
|
|
align="start"
|
|
role="img"
|
|
aria-label="Reactions: thumbs up, surprised, fire, eyes"
|
|
>
|
|
<span>👍</span>
|
|
<span>😮</span>
|
|
<span>🔥</span>
|
|
<span>👀</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Marker variant="separator">
|
|
<MarkerContent>side=bottom align=end</MarkerContent>
|
|
</Marker>
|
|
<Bubble variant="muted">
|
|
<BubbleContent>This is a one line message.</BubbleContent>
|
|
<BubbleReactions
|
|
side="top"
|
|
align="end"
|
|
role="img"
|
|
aria-label="Reaction: thumbs up"
|
|
>
|
|
<span>👍</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Bubble variant="muted">
|
|
<BubbleContent>
|
|
A longer message that wraps across lines so the reaction offset.
|
|
</BubbleContent>
|
|
<BubbleReactions
|
|
side="top"
|
|
align="end"
|
|
role="img"
|
|
aria-label="Reactions: thumbs up, surprised, fire, eyes"
|
|
className="px-1.5 py-0.5"
|
|
>
|
|
<span>👍</span>
|
|
<span>😮</span>
|
|
<span>🔥</span>
|
|
<span>👀</span>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
function BubbleReactionsButtons() {
|
|
return (
|
|
<Example title="Reactions Buttons">
|
|
<div className="flex w-full max-w-md flex-col gap-8">
|
|
<Bubble>
|
|
<BubbleContent>This is a one line message.</BubbleContent>
|
|
<BubbleReactions>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() =>
|
|
toast("You clicked the button in the bubble reaction")
|
|
}
|
|
>
|
|
Button
|
|
</Button>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Bubble align="end">
|
|
<BubbleContent>This is a one line message.</BubbleContent>
|
|
<BubbleReactions align="start">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => toast("Confetti!")}
|
|
>
|
|
🎉
|
|
</Button>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
<Bubble variant="tinted">
|
|
<BubbleContent>
|
|
We are going to the movies first then dinner. Are you in?
|
|
</BubbleContent>
|
|
<BubbleReactions className="gap-1 bg-background">
|
|
<Button
|
|
variant="secondary"
|
|
size="icon-xs"
|
|
aria-label="Thumbs up"
|
|
onClick={() => toast("You agree!")}
|
|
>
|
|
<IconPlaceholder
|
|
lucide="ThumbsUpIcon"
|
|
tabler="IconThumbUp"
|
|
hugeicons="ThumbsUpIcon"
|
|
phosphor="ThumbsUpIcon"
|
|
remixicon="RiThumbUpLine"
|
|
/>
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon-xs"
|
|
aria-label="Thumbs down"
|
|
onClick={() => toast("You disagree!")}
|
|
>
|
|
<IconPlaceholder
|
|
lucide="ThumbsDownIcon"
|
|
tabler="IconThumbDown"
|
|
hugeicons="ThumbsDownIcon"
|
|
phosphor="ThumbsDownIcon"
|
|
remixicon="RiThumbDownLine"
|
|
/>
|
|
</Button>
|
|
</BubbleReactions>
|
|
</Bubble>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
function BubbleAlignment() {
|
|
return (
|
|
<Example title="Alignment">
|
|
<div className="flex w-full max-w-md flex-col gap-8">
|
|
<Bubble variant="muted">
|
|
<BubbleContent>This bubble is aligned to the start.</BubbleContent>
|
|
</Bubble>
|
|
<Bubble align="end">
|
|
<BubbleContent>This bubble is aligned to the end.</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="muted">
|
|
<BubbleContent>
|
|
This multiline bubble is aligned to the start. The corners should
|
|
adjust when the text wraps to show the grouped side of the
|
|
conversation.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble align="end">
|
|
<BubbleContent>
|
|
This multiline bubble is aligned to the end. It should sit on the
|
|
opposite side with the matching corner radius for wrapped text.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|
|
|
|
const quickReplies = [
|
|
{
|
|
label: "I need help with my account.",
|
|
message: "I need help with my account.",
|
|
},
|
|
{
|
|
label: "I forgot my password.",
|
|
message: "I forgot my password.",
|
|
},
|
|
{
|
|
label:
|
|
"I have another question. I'd like to talk to a human. Can you help me?",
|
|
message: "I have another question.",
|
|
},
|
|
]
|
|
|
|
function BubbleButtonLinks() {
|
|
return (
|
|
<Example title="Button & Links">
|
|
<div className="flex w-full max-w-md flex-col gap-8">
|
|
<Bubble>
|
|
<BubbleContent render={<a href="#" />}>
|
|
This bubble is a link.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="secondary">
|
|
<BubbleContent render={<button type="button" />}>
|
|
This one is a button you can click.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Bubble variant="muted">
|
|
<BubbleContent render={<button type="button" />}>
|
|
You can also do tinted buttons. Even ones that are multilines.
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<Marker variant="separator">
|
|
<MarkerContent>Chat Suggestions</MarkerContent>
|
|
</Marker>
|
|
<Bubble>
|
|
<BubbleContent>How can I help you today?</BubbleContent>
|
|
</Bubble>
|
|
<BubbleGroup>
|
|
{quickReplies.map((reply) => (
|
|
<Bubble key={reply.label} variant="outline" align="end">
|
|
<BubbleContent
|
|
className="border-dashed border-primary"
|
|
render={
|
|
<button type="button" onClick={() => toast(reply.message)} />
|
|
}
|
|
>
|
|
{reply.label}
|
|
</BubbleContent>
|
|
</Bubble>
|
|
))}
|
|
</BubbleGroup>
|
|
</div>
|
|
</Example>
|
|
)
|
|
}
|