Bubble
Free, copy-and-go Bubble components built on the SevenUI Bubble primitive.Read the primitive docs.
"use client";
import { cn } from "cn";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
type Run = {
id: string;
from: "them" | "me";
messages: string[];
};
const runs: Run[] = [
{
id: "run-1",
from: "them",
messages: [
"Morning! The staging build is green again.",
"I reverted the font preload change that broke Safari.",
"Can you check the checkout flow before lunch?",
],
},
{
id: "run-2",
from: "me",
messages: ["On it.", "Running the payment smoke tests now."],
},
{
id: "run-3",
from: "them",
messages: ["Perfect, thank you."],
},
];
// Square off the corner that faces the sender's side, except on the
// last bubble of a run, which keeps its tail corner.
function cornerClass(from: Run["from"], index: number, total: number) {
const isFirst = index === 0;
const isLast = index === total - 1;
if (from === "them") {
return cn(!isFirst && "rounded-tl-md", !isLast && "rounded-bl-md");
}
return cn(!isFirst && "rounded-tr-md", !isLast && "rounded-br-md");
}
export default function Bubble01() {
return (
<div
role="log"
aria-label="Conversation with Maya Chen"
className="flex w-full max-w-md flex-col gap-4"
>
{runs.map((run) => (
<BubbleGroup
key={run.id}
className={cn("gap-0.5", run.from === "me" && "items-end")}
>
{run.messages.map((message, index) => (
<Bubble
key={message}
variant={run.from === "me" ? "default" : "muted"}
align={run.from === "me" ? "end" : "start"}
>
<BubbleContent
className={cornerClass(run.from, index, run.messages.length)}
>
{message}
</BubbleContent>
</Bubble>
))}
</BubbleGroup>
))}
</div>
);
}
npx shadcn@latest add @sevenui/component/bubble-01pnpm dlx shadcn@latest add @sevenui/component/bubble-01yarn dlx shadcn@latest add @sevenui/component/bubble-01bunx --bun shadcn@latest add @sevenui/component/bubble-01"use client";
import { CheckCheckIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
} from "@/components/ui/message";
export default function Bubble02() {
return (
<MessageGroup className="w-full max-w-md gap-5">
<Message>
<MessageAvatar>
<Avatar>
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>DO</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<MessageHeader>
Daniel Okafor
<span className="ml-1.5 font-normal">· Support</span>
</MessageHeader>
<BubbleGroup className="gap-1">
<Bubble variant="secondary">
<BubbleContent>
Your refund for order SO-48213 was approved this morning.
</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>
It should reach your card within 3–5 business days.
</BubbleContent>
</Bubble>
</BubbleGroup>
<MessageFooter>
<time dateTime="2026-09-25T09:41">9:41 AM</time>
</MessageFooter>
</MessageContent>
</Message>
<Message align="end">
<MessageAvatar>
<Avatar>
<AvatarFallback>JL</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<Bubble align="end">
<BubbleContent>That was fast. Thanks, Daniel!</BubbleContent>
</Bubble>
<MessageFooter className="gap-1">
<time dateTime="2026-09-25T09:43">9:43 AM</time>
<span aria-hidden="true">·</span>
<CheckCheckIcon aria-hidden="true" className="size-3.5" />
<span>Read</span>
</MessageFooter>
</MessageContent>
</Message>
</MessageGroup>
);
}
npx shadcn@latest add @sevenui/component/bubble-02pnpm dlx shadcn@latest add @sevenui/component/bubble-02yarn dlx shadcn@latest add @sevenui/component/bubble-02bunx --bun shadcn@latest add @sevenui/component/bubble-02"use client";
import * as React from "react";
import { CornerUpLeftIcon, SendHorizontalIcon, XIcon } from "lucide-react";
import { cn } from "cn";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
type ChatMessage = {
id: string;
author: string;
mine: boolean;
text: string;
replyTo?: string;
};
const initialMessages: ChatMessage[] = [
{
id: "q1",
author: "Maya Chen",
mine: false,
text: "The hotel block closes Friday, so I need a final headcount by Thursday.",
},
{
id: "q2",
author: "Maya Chen",
mine: false,
text: "Also, does anyone need a vegetarian option for the team dinner?",
},
{
id: "q3",
author: "You",
mine: true,
text: "Put me down for vegetarian, please.",
replyTo: "q2",
},
];
export default function Bubble03() {
const [messages, setMessages] = React.useState(initialMessages);
const [replyTo, setReplyTo] = React.useState<ChatMessage | null>(null);
const [highlighted, setHighlighted] = React.useState<string | null>(null);
const [draft, setDraft] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const nextId = React.useRef(1);
React.useEffect(() => {
if (!highlighted) return;
const timeout = window.setTimeout(() => setHighlighted(null), 1600);
return () => window.clearTimeout(timeout);
}, [highlighted]);
function startReply(message: ChatMessage) {
setReplyTo(message);
inputRef.current?.focus();
}
function jumpTo(id: string) {
document
.getElementById(`bubble-03-${id}`)
?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
setHighlighted(id);
}
function send() {
const text = draft.trim();
if (!text) return;
const id = `new-${nextId.current++}`;
setMessages((current) => [
...current,
{ id, author: "You", mine: true, text, replyTo: replyTo?.id },
]);
setDraft("");
setReplyTo(null);
}
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div
role="log"
aria-label="Offsite planning"
className="flex flex-col gap-2"
>
{messages.map((message) => {
const quoted = messages.find((item) => item.id === message.replyTo);
return (
<div
key={message.id}
className={cn(
"group flex items-center gap-1",
message.mine && "flex-row-reverse",
)}
>
<Bubble
variant={message.mine ? "default" : "muted"}
align={message.mine ? "end" : "start"}
>
<BubbleContent
id={`bubble-03-${message.id}`}
className={cn(
"flex flex-col gap-1.5 transition-shadow",
highlighted === message.id &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
>
{quoted ? (
<button
type="button"
onClick={() => jumpTo(quoted.id)}
aria-label={`Replying to ${quoted.author}: ${quoted.text}. Show original message`}
className={cn(
"flex flex-col rounded-xl px-2.5 py-1.5 text-left text-xs outline-none focus-visible:ring-2",
message.mine
? "bg-primary-foreground/15 hover:bg-primary-foreground/25 focus-visible:ring-primary-foreground"
: "bg-background/70 hover:bg-background focus-visible:ring-ring",
)}
>
<span className="font-medium">{quoted.author}</span>
<span className="line-clamp-1 opacity-80">
{quoted.text}
</span>
</button>
) : null}
<span>{message.text}</span>
</BubbleContent>
</Bubble>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Reply to ${message.author}`}
onClick={() => startReply(message)}
className="shrink-0 text-muted-foreground sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100"
>
<CornerUpLeftIcon aria-hidden="true" />
</Button>
</div>
);
})}
</div>
<form
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
{replyTo ? (
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2 text-xs">
<CornerUpLeftIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">Replying to {replyTo.author}</span>
<span className="truncate text-muted-foreground">
{replyTo.text}
</span>
</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Cancel reply"
onClick={() => setReplyTo(null)}
>
<XIcon aria-hidden="true" />
</Button>
</div>
) : null}
<InputGroup>
<InputGroupInput
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setReplyTo(null);
}}
placeholder={replyTo ? "Write a reply" : "Message Maya"}
aria-label={
replyTo ? `Reply to ${replyTo.author}` : "Message Maya Chen"
}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
variant="default"
size="icon-xs"
aria-label="Send message"
disabled={!draft.trim()}
>
<SendHorizontalIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</div>
);
}
npx shadcn@latest add @sevenui/component/bubble-03pnpm dlx shadcn@latest add @sevenui/component/bubble-03yarn dlx shadcn@latest add @sevenui/component/bubble-03bunx --bun shadcn@latest add @sevenui/component/bubble-03"use client";
import * as React from "react";
import { PauseIcon, PlayIcon } from "lucide-react";
import { cn } from "cn";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
} from "@/components/ui/message";
const duration = 24;
const waveform = [
0.3, 0.5, 0.8, 0.6, 0.9, 0.4, 0.7, 1, 0.6, 0.3, 0.5, 0.8, 0.9, 0.7, 0.4, 0.6,
0.8, 0.5, 0.3, 0.6, 0.9, 1, 0.7, 0.5, 0.4, 0.7, 0.8, 0.6, 0.4, 0.3, 0.5, 0.3,
];
const speeds = [1, 1.5, 2];
const transcript =
"Quick update from the Northwind call. They signed off on the redesign, but want the mobile checkout in the first release. I told them we'd send a revised timeline by Wednesday.";
function formatTime(seconds: number) {
const whole = Math.floor(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
}
export default function Bubble04() {
const [playing, setPlaying] = React.useState(false);
const [elapsed, setElapsed] = React.useState(0);
const [speed, setSpeed] = React.useState(1);
const [showTranscript, setShowTranscript] = React.useState(false);
React.useEffect(() => {
if (!playing) return;
const interval = window.setInterval(() => {
setElapsed((current) => Math.min(current + 0.1 * speed, duration));
}, 100);
return () => window.clearInterval(interval);
}, [playing, speed]);
React.useEffect(() => {
if (elapsed >= duration) setPlaying(false);
}, [elapsed]);
function togglePlay() {
if (!playing && elapsed >= duration) setElapsed(0);
setPlaying((current) => !current);
}
const progress = elapsed / duration;
const started = playing || elapsed > 0;
return (
<div className="flex w-full max-w-md flex-col gap-4">
<Bubble align="end">
<BubbleContent>
Can you give me the short version of the call?
</BubbleContent>
</Bubble>
<Message>
<MessageAvatar>
<Avatar>
<AvatarFallback>RA</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<MessageHeader>Rosa Alvarez</MessageHeader>
<Bubble variant="muted" className="w-72 max-w-full">
<BubbleContent className="flex w-full items-center gap-2.5 py-2 pl-2">
<Button
size="icon"
className="shrink-0 rounded-full"
aria-label={
playing ? "Pause voice message" : "Play voice message"
}
onClick={togglePlay}
>
{playing ? (
<PauseIcon aria-hidden="true" />
) : (
<PlayIcon aria-hidden="true" />
)}
</Button>
<div className="relative flex h-8 min-w-0 flex-1 items-center gap-0.5 rounded-md has-focus-visible:ring-2 has-focus-visible:ring-ring">
{waveform.map((height, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: static waveform
key={index}
aria-hidden="true"
style={{ height: `${height * 100}%` }}
className={cn(
"min-w-0 flex-1 rounded-full transition-colors",
index / waveform.length < progress
? "bg-foreground"
: "bg-muted-foreground/40",
)}
/>
))}
<input
type="range"
min={0}
max={duration}
step={1}
value={Math.round(elapsed)}
onChange={(event) => setElapsed(Number(event.target.value))}
aria-label="Seek"
aria-valuetext={`${formatTime(elapsed)} of ${formatTime(duration)}`}
className="absolute inset-0 size-full cursor-pointer opacity-0"
/>
</div>
<span className="w-8 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatTime(started ? elapsed : duration)}
</span>
</BubbleContent>
{showTranscript ? (
<BubbleContent
id="bubble-04-transcript"
className="text-muted-foreground"
>
{transcript}
</BubbleContent>
) : null}
</Bubble>
<MessageFooter className="gap-1 px-1">
<Button
variant="ghost"
size="xs"
className="tabular-nums"
aria-label={`Playback speed ${speed}x`}
onClick={() =>
setSpeed(
(current) =>
speeds[(speeds.indexOf(current) + 1) % speeds.length],
)
}
>
{speed}x
</Button>
<Button
variant="ghost"
size="xs"
aria-expanded={showTranscript}
aria-controls="bubble-04-transcript"
onClick={() => setShowTranscript((current) => !current)}
>
{showTranscript ? "Hide transcript" : "Show transcript"}
</Button>
</MessageFooter>
</MessageContent>
</Message>
</div>
);
}
npx shadcn@latest add @sevenui/component/bubble-04pnpm dlx shadcn@latest add @sevenui/component/bubble-04yarn dlx shadcn@latest add @sevenui/component/bubble-04bunx --bun shadcn@latest add @sevenui/component/bubble-041 selected
"use client";
import * as React from "react";
import {
CheckIcon,
CopyIcon,
RotateCcwIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { cn } from "cn";
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
const initialMessages = [
{
id: "s1",
mine: false,
text: "Wi-Fi password for the studio is on the fridge.",
},
{ id: "s2", mine: false, text: "Door code is 4471, it changes Friday." },
{ id: "s3", mine: true, text: "Got it. Parking is behind the bakery?" },
{ id: "s4", mine: false, text: "Yes, spots 12 and 14 are ours." },
];
export default function Bubble05() {
const [messages, setMessages] = React.useState(initialMessages);
const [selected, setSelected] = React.useState<string[]>(["s2"]);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(timeout);
}, [copied]);
function clearSelection() {
setCopied(false);
setSelected([]);
}
function toggle(id: string) {
setCopied(false);
setSelected((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id],
);
}
function copySelected() {
const text = messages
.filter((message) => selected.includes(message.id))
.map((message) => message.text)
.join("\n");
navigator.clipboard?.writeText(text).catch(() => {});
setCopied(true);
}
function deleteSelected() {
setMessages((current) =>
current.filter((message) => !selected.includes(message.id)),
);
clearSelection();
}
function restoreMessages() {
setMessages(initialMessages);
clearSelection();
}
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div
role="toolbar"
aria-label="Selected messages"
className="flex h-10 items-center gap-1 rounded-xl border bg-card px-2"
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Clear selection"
disabled={selected.length === 0}
onClick={clearSelection}
>
<XIcon aria-hidden="true" />
</Button>
<span aria-live="polite" className="mr-auto text-sm tabular-nums">
{copied
? "Copied to clipboard"
: selected.length === 0
? "Tap messages to select"
: `${selected.length} selected`}
</span>
<Button
variant="ghost"
size="icon-sm"
aria-label="Copy selected messages"
disabled={selected.length === 0}
onClick={copySelected}
>
<CopyIcon aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete selected messages"
disabled={selected.length === 0}
onClick={deleteSelected}
className="text-destructive hover:text-destructive"
>
<Trash2Icon aria-hidden="true" />
</Button>
</div>
<div className="flex flex-col gap-3">
{messages.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-6 text-center text-sm text-muted-foreground">
<p>No messages left in this thread.</p>
<Button variant="outline" size="xs" onClick={restoreMessages}>
<RotateCcwIcon aria-hidden="true" data-icon="inline-start" />
Restore messages
</Button>
</div>
) : (
messages.map((message) => {
const isSelected = selected.includes(message.id);
return (
<Bubble
key={message.id}
variant={message.mine ? "default" : "muted"}
align={message.mine ? "end" : "start"}
>
<BubbleContent
render={<button type="button" />}
aria-pressed={isSelected}
onClick={() => toggle(message.id)}
className={cn(
"ring-offset-2 ring-offset-background",
isSelected && "ring-2 ring-primary",
)}
>
{message.text}
</BubbleContent>
{isSelected ? (
<BubbleReactions
side="top"
align={message.mine ? "start" : "end"}
className="size-5 bg-primary p-0 text-primary-foreground ring-background"
>
<CheckIcon aria-hidden="true" className="size-3" />
</BubbleReactions>
) : null}
</Bubble>
);
})
)}
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/bubble-05pnpm dlx shadcn@latest add @sevenui/component/bubble-05yarn dlx shadcn@latest add @sevenui/component/bubble-05bunx --bun shadcn@latest add @sevenui/component/bubble-05"use client";
import * as React from "react";
import {
HeartIcon,
LaughIcon,
type LucideIcon,
PartyPopperIcon,
SmilePlusIcon,
ThumbsUpIcon,
} from "lucide-react";
import { cn } from "cn";
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type ReactionKey = "like" | "love" | "celebrate" | "funny";
const reactionIcons: Record<ReactionKey, { icon: LucideIcon; label: string }> =
{
like: { icon: ThumbsUpIcon, label: "Like" },
love: { icon: HeartIcon, label: "Love" },
celebrate: { icon: PartyPopperIcon, label: "Celebrate" },
funny: { icon: LaughIcon, label: "Funny" },
};
type Reaction = { count: number; mine: boolean };
const initialMessages: {
id: string;
mine: boolean;
text: string;
reactions: Partial<Record<ReactionKey, Reaction>>;
}[] = [
{
id: "r1",
mine: false,
text: "The new onboarding flow cut drop-off by 18% in the first week.",
reactions: {
celebrate: { count: 4, mine: false },
like: { count: 2, mine: true },
},
},
{
id: "r2",
mine: true,
text: "Huge. Drinks are on me at the offsite.",
reactions: { funny: { count: 3, mine: false } },
},
];
export default function Bubble06() {
const [messages, setMessages] = React.useState(initialMessages);
function toggleReaction(messageId: string, key: ReactionKey) {
setMessages((current) =>
current.map((message) => {
if (message.id !== messageId) return message;
const existing = message.reactions[key] ?? { count: 0, mine: false };
const next: Reaction = existing.mine
? { count: existing.count - 1, mine: false }
: { count: existing.count + 1, mine: true };
const reactions = { ...message.reactions };
if (next.count <= 0) delete reactions[key];
else reactions[key] = next;
return { ...message, reactions };
}),
);
}
return (
<div
role="log"
aria-label="Growth team chat"
className="flex w-full max-w-md flex-col gap-9 pb-4"
>
{messages.map((message) => {
const entries = Object.entries(message.reactions) as [
ReactionKey,
Reaction,
][];
return (
<div
key={message.id}
className={cn(
"flex items-center gap-1",
message.mine && "flex-row-reverse",
)}
>
<Bubble
variant={message.mine ? "default" : "muted"}
align={message.mine ? "end" : "start"}
>
<BubbleContent>{message.text}</BubbleContent>
{entries.length > 0 ? (
<BubbleReactions
align={message.mine ? "start" : "end"}
className="gap-0.5 px-0.5"
>
{entries.map(([key, reaction]) => {
const { icon: Icon, label } = reactionIcons[key];
return (
<button
key={key}
type="button"
aria-pressed={reaction.mine}
aria-label={`${label}, ${reaction.count}`}
onClick={() => toggleReaction(message.id, key)}
className={cn(
"flex h-6 items-center gap-1 rounded-full px-1.5 text-xs tabular-nums transition-colors outline-none hover:bg-background focus-visible:ring-2 focus-visible:ring-ring",
reaction.mine &&
"bg-primary text-primary-foreground hover:bg-primary/85",
)}
>
<Icon aria-hidden="true" className="size-3.5" />
{reaction.count}
</button>
);
})}
</BubbleReactions>
) : null}
</Bubble>
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
aria-label="Add reaction"
/>
}
>
<SmilePlusIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverContent
side="top"
className="w-fit flex-row gap-0.5 rounded-full p-1"
>
{(Object.keys(reactionIcons) as ReactionKey[]).map((key) => {
const { icon: Icon, label } = reactionIcons[key];
return (
<Button
key={key}
variant={
message.reactions[key]?.mine ? "secondary" : "ghost"
}
size="icon-sm"
className="rounded-full"
aria-label={label}
aria-pressed={message.reactions[key]?.mine ?? false}
onClick={() => toggleReaction(message.id, key)}
>
<Icon aria-hidden="true" />
</Button>
);
})}
</PopoverContent>
</Popover>
</div>
);
})}
</div>
);
}
npx shadcn@latest add @sevenui/component/bubble-06pnpm dlx shadcn@latest add @sevenui/component/bubble-06yarn dlx shadcn@latest add @sevenui/component/bubble-06bunx --bun shadcn@latest add @sevenui/component/bubble-06"use client";
import { ArrowUpRightIcon, DownloadIcon, FileTextIcon } from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
export default function Bubble07() {
return (
<div
role="log"
aria-label="Shared media"
className="flex w-full max-w-md flex-col gap-4"
>
<BubbleGroup className="gap-1">
<Bubble variant="muted">
<BubbleContent className="flex flex-col gap-2 p-1.5 pb-2.5">
<img
src="/placeholder.svg"
alt="Moodboard for the autumn campaign with three product shots"
width={320}
height={200}
className="aspect-16/10 w-full max-w-72 rounded-[1.1rem] bg-background object-cover"
/>
<span className="px-1.5">Moodboard for the autumn campaign.</span>
</BubbleContent>
</Bubble>
<Bubble variant="ghost">
<Attachment className="w-full max-w-72">
<AttachmentMedia>
<FileTextIcon aria-hidden="true" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>campaign-brief-v3.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Download campaign-brief-v3.pdf">
<DownloadIcon aria-hidden="true" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</Bubble>
</BubbleGroup>
<Bubble variant="outline" align="end">
<BubbleContent
render={<a href="#figma-file" />}
className="flex max-w-72 flex-col gap-1 p-3"
>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
figma.com
<ArrowUpRightIcon aria-hidden="true" className="size-3" />
</span>
<span className="font-medium">Autumn campaign — key visuals</span>
<span className="line-clamp-2 text-muted-foreground">
12 frames, updated 2 hours ago by Priya Nair. Comments are open for
the product team.
</span>
</BubbleContent>
</Bubble>
</div>
);
}
npx shadcn@latest add @sevenui/component/bubble-07pnpm dlx shadcn@latest add @sevenui/component/bubble-07yarn dlx shadcn@latest add @sevenui/component/bubble-07bunx --bun shadcn@latest add @sevenui/component/bubble-07"use client";
import * as React from "react";
import { RotateCcwIcon, SparklesIcon } from "lucide-react";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
const question = "How do I rotate an API key without downtime?";
const answer =
"Create a second key first and deploy it alongside the old one. Once every service reads the new key, revoke the old key from Settings → API keys. Requests signed with either key keep working during the overlap.";
const words = answer.split(" ");
type Phase = "typing" | "streaming" | "done";
export default function Bubble08() {
const [phase, setPhase] = React.useState<Phase>("typing");
const [count, setCount] = React.useState(0);
const [run, setRun] = React.useState(0);
// Replays the typing indicator and word-by-word stream on every run.
// biome-ignore lint/correctness/useExhaustiveDependencies: `run` restarts the sequence
React.useEffect(() => {
setPhase("typing");
setCount(0);
let interval: number | undefined;
const delay = window.setTimeout(() => {
setPhase("streaming");
interval = window.setInterval(() => {
setCount((current) => {
const next = current + 1;
if (next >= words.length) {
window.clearInterval(interval);
setPhase("done");
}
return next;
});
}, 60);
}, 1200);
return () => {
window.clearTimeout(delay);
window.clearInterval(interval);
};
}, [run]);
return (
<div className="flex w-full max-w-md flex-col gap-4">
<Bubble align="end" variant="secondary">
<BubbleContent>{question}</BubbleContent>
</Bubble>
<div className="flex gap-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<SparklesIcon aria-hidden="true" className="size-3.5" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{phase === "typing" ? (
<Bubble variant="muted">
<BubbleContent
role="status"
aria-label="Assistant is typing"
className="flex h-9 items-center gap-1 px-3.5"
>
{[0, 150, 300].map((delay) => (
<span
key={delay}
aria-hidden="true"
style={{ animationDelay: `${delay}ms` }}
className="size-1.5 animate-pulse rounded-full bg-muted-foreground motion-reduce:animate-none"
/>
))}
</BubbleContent>
</Bubble>
) : (
<Bubble
variant="ghost"
className="animate-in duration-300 fade-in-0 slide-in-from-bottom-1"
>
<BubbleContent aria-busy={phase === "streaming"}>
<span className="sr-only">
{phase === "done" ? answer : ""}
</span>
<span aria-hidden="true">
{words.slice(0, count).join(" ")}
{phase === "streaming" ? (
<span className="ml-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 animate-pulse rounded-xs bg-foreground motion-reduce:animate-none" />
) : null}
</span>
</BubbleContent>
</Bubble>
)}
<div className="flex h-7 items-center">
{phase === "done" ? (
<Button
variant="ghost"
size="xs"
className="-ml-2 text-muted-foreground animate-in fade-in-0"
onClick={() => setRun((current) => current + 1)}
>
<RotateCcwIcon aria-hidden="true" data-icon="inline-start" />
Regenerate
</Button>
) : null}
</div>
</div>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/bubble-08pnpm dlx shadcn@latest add @sevenui/component/bubble-08yarn dlx shadcn@latest add @sevenui/component/bubble-08bunx --bun shadcn@latest add @sevenui/component/bubble-08Help center
Instant answers, people on call 9–6 CET
"use client";
import * as React from "react";
import { LifeBuoyIcon, RotateCcwIcon } from "lucide-react";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
type Topic = {
id: string;
label: string;
answer: string;
};
const topics: Topic[] = [
{
id: "invoice",
label: "Where is my invoice?",
answer:
"Invoices live in Settings → Billing → History. Each one is emailed to the billing contact on the day it is issued.",
},
{
id: "seats",
label: "Add seats to my plan",
answer:
"Owners can add seats from Settings → Members. You are only charged for the remaining days of the current cycle.",
},
{
id: "export",
label: "Export my workspace data",
answer:
"Go to Settings → Workspace → Export. We package every project as a ZIP and email you a download link within 10 minutes.",
},
];
type Turn = { from: "user" | "bot"; text: string };
export default function Bubble09() {
const [turns, setTurns] = React.useState<Turn[]>([]);
const answered = turns.length > 0;
function pick(topic: Topic) {
setTurns([
{ from: "user", text: topic.label },
{ from: "bot", text: topic.answer },
]);
}
return (
<section
aria-label="Help chat"
className="flex w-full max-w-sm flex-col overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm"
>
<header className="flex items-center gap-3 border-b px-4 py-3">
<span className="flex size-8 items-center justify-center rounded-full bg-muted">
<LifeBuoyIcon aria-hidden className="size-4" />
</span>
<div className="flex min-w-0 flex-1 flex-col">
<h2 className="text-sm font-medium">Help center</h2>
<p className="text-xs text-muted-foreground">
Instant answers, people on call{" "}
<span className="whitespace-nowrap">9–6 CET</span>
</p>
</div>
{answered && (
<Button
variant="ghost"
size="icon-sm"
aria-label="Start over"
onClick={() => setTurns([])}
>
<RotateCcwIcon aria-hidden />
</Button>
)}
</header>
<div className="flex flex-col gap-4 p-4" aria-live="polite">
<BubbleGroup>
<Bubble variant="muted">
<BubbleContent>
Hi Maya — what can we help you with today?
</BubbleContent>
</Bubble>
</BubbleGroup>
{turns.map((turn) => (
<Bubble
key={turn.from}
align={turn.from === "user" ? "end" : "start"}
variant={turn.from === "user" ? "default" : "muted"}
>
<BubbleContent>{turn.text}</BubbleContent>
</Bubble>
))}
{!answered ? (
<fieldset className="min-w-0 flex flex-col items-end gap-1.5">
<legend className="sr-only">Suggested questions</legend>
{topics.map((topic) => (
<Bubble key={topic.id} variant="outline" align="end">
<BubbleContent
render={<button type="button" />}
onClick={() => pick(topic)}
>
{topic.label}
</BubbleContent>
</Bubble>
))}
</fieldset>
) : (
<div className="flex flex-wrap justify-end gap-1.5">
<Bubble variant="outline" align="end">
<BubbleContent
render={<button type="button" />}
onClick={() => setTurns([])}
>
Ask something else
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent render={<a href="#contact" />}>
Talk to a person
</BubbleContent>
</Bubble>
</div>
)}
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/bubble-09pnpm dlx shadcn@latest add @sevenui/component/bubble-09yarn dlx shadcn@latest add @sevenui/component/bubble-09bunx --bun shadcn@latest add @sevenui/component/bubble-09"use client";
import * as React from "react";
import { BikeIcon, PhoneIcon, SendHorizontalIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { Progress } from "@/components/ui/progress";
const presets = [
"Leave it at the door",
"Please ring the bell",
"Coming down now",
];
const courierMessages = [
"Hi! I picked up your order from Green Fork.",
"I'm about 6 minutes away. Anything I should know about the building?",
];
export default function Bubble10() {
const [sent, setSent] = React.useState<{ id: number; text: string }[]>([]);
const nextId = React.useRef(1);
const [draft, setDraft] = React.useState("");
function send(text: string) {
const value = text.trim();
if (!value) return;
const id = nextId.current++;
setSent((current) => [...current, { id, text: value }]);
setDraft("");
}
return (
<section
aria-label="Chat with your courier"
className="flex w-full max-w-xs flex-col overflow-hidden rounded-3xl border bg-background shadow-sm"
>
<header className="flex flex-col gap-3 border-b bg-card px-4 pt-4 pb-3">
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>DK</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<h2 className="truncate text-sm font-medium">
Daniel · your courier
</h2>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<BikeIcon aria-hidden className="size-3.5" />
Order 4821 · arriving 12:42
</p>
</div>
<Button
variant="outline"
size="icon"
aria-label="Call Daniel"
nativeButton={false}
render={<a href="tel:+15550148821" />}
>
<PhoneIcon aria-hidden />
</Button>
</div>
<Progress value={78} aria-label="Delivery progress" />
</header>
<div className="flex flex-col gap-3 px-3 py-4" aria-live="polite">
<BubbleGroup>
{courierMessages.map((message) => (
<Bubble key={message} variant="secondary">
<BubbleContent>{message}</BubbleContent>
</Bubble>
))}
</BubbleGroup>
{sent.length > 0 && (
<BubbleGroup>
{sent.map((message) => (
<Bubble key={message.id} align="end">
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
))}
<p className="self-end px-3 text-xs text-muted-foreground">
Delivered
</p>
</BubbleGroup>
)}
</div>
<div className="flex flex-col gap-2 border-t bg-card p-3">
<fieldset className="min-w-0 flex flex-wrap gap-1.5">
<legend className="sr-only">Quick replies</legend>
{presets.map((preset) => (
<Button
key={preset}
variant="outline"
size="xs"
className="rounded-full"
onClick={() => send(preset)}
>
{preset}
</Button>
))}
</fieldset>
<form
onSubmit={(event) => {
event.preventDefault();
send(draft);
}}
>
<InputGroup className="rounded-full">
<InputGroupInput
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Message Daniel"
aria-label="Message Daniel"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-xs"
variant="default"
className="rounded-full"
aria-label="Send message"
disabled={!draft.trim()}
>
<SendHorizontalIcon aria-hidden />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/bubble-10pnpm dlx shadcn@latest add @sevenui/component/bubble-10yarn dlx shadcn@latest add @sevenui/component/bubble-10bunx --bun shadcn@latest add @sevenui/component/bubble-10Duplicate charge on Pro plan
Ticket 4821Open"use client";
import * as React from "react";
import { LockIcon, SendHorizontalIcon } from "lucide-react";
import { cn } from "cn";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Message,
MessageAvatar,
MessageContent,
MessageHeader,
} from "@/components/ui/message";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Mode = "reply" | "note";
type Entry = {
id: string;
kind: "customer" | "reply" | "note";
author: string;
initials: string;
text: string;
};
const initialEntries: Entry[] = [
{
id: "t1",
kind: "customer",
author: "Hannah Brooks",
initials: "HB",
text: "I was charged twice for the Pro plan this month. Can you refund the duplicate?",
},
{
id: "t2",
kind: "note",
author: "Leo Martin",
initials: "LM",
text: "Confirmed in Stripe: two charges 40 seconds apart after a retry. Safe to refund the second one.",
},
];
// Internal notes reuse the outline bubble with a dashed warning treatment,
// so they never read as something the customer can see.
const noteBubble =
"*:data-[slot=bubble-content]:border-dashed *:data-[slot=bubble-content]:border-warning *:data-[slot=bubble-content]:bg-warning/10";
export default function Bubble11() {
const [entries, setEntries] = React.useState(initialEntries);
const [mode, setMode] = React.useState<Mode>("reply");
const [draft, setDraft] = React.useState("");
const nextId = React.useRef(1);
function submit() {
const text = draft.trim();
if (!text) return;
const id = `new-${nextId.current++}`;
setEntries((current) => [
...current,
{ id, kind: mode, author: "You", initials: "ME", text },
]);
setDraft("");
}
const isNote = mode === "note";
return (
<section
aria-labelledby="bubble-11-title"
className="flex w-full max-w-md flex-col rounded-xl border bg-card text-card-foreground"
>
<header className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<h2 id="bubble-11-title" className="text-sm font-medium">
Duplicate charge on Pro plan
</h2>
<span className="text-xs text-muted-foreground tabular-nums">
Ticket 4821
</span>
<Badge variant="secondary" className="ml-auto">
Open
</Badge>
</header>
<div
role="log"
aria-label="Ticket conversation"
className="flex flex-col gap-4 px-4 py-4"
>
{entries.map((entry) => {
const outgoing = entry.kind !== "customer";
return (
<Message key={entry.id} align={outgoing ? "end" : "start"}>
<MessageAvatar>
<Avatar size="sm">
<AvatarFallback>{entry.initials}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<MessageHeader className="gap-1">
{entry.author}
{entry.kind === "note" ? (
<span className="flex items-center gap-1 font-normal">
<LockIcon aria-hidden="true" className="size-3" />
Internal note
</span>
) : null}
</MessageHeader>
<Bubble
align={outgoing ? "end" : "start"}
variant={
entry.kind === "customer"
? "muted"
: entry.kind === "note"
? "outline"
: "default"
}
className={cn(entry.kind === "note" && noteBubble)}
>
<BubbleContent>{entry.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
);
})}
</div>
<form
className={cn(
"flex flex-col gap-2 rounded-b-xl border-t p-3 transition-colors",
isNote && "bg-warning/10",
)}
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<ToggleGroup
aria-label="Response type"
variant="outline"
size="sm"
spacing={0}
value={[mode]}
onValueChange={(value) => {
const next = value[0] as Mode | undefined;
if (next) setMode(next);
}}
>
<ToggleGroupItem value="reply">Reply</ToggleGroupItem>
<ToggleGroupItem value="note">
<LockIcon aria-hidden="true" data-icon="inline-start" />
Internal note
</ToggleGroupItem>
</ToggleGroup>
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={
isNote
? "Only your team will see this note"
: "Write a reply to Hannah"
}
aria-label={isNote ? "Internal note" : "Reply to Hannah Brooks"}
className="min-h-16 bg-background dark:bg-background"
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{isNote ? "Hidden from the customer" : "Sent to hannah@brooks.co"}
</p>
<Button
type="submit"
size="sm"
variant={isNote ? "outline" : "default"}
disabled={!draft.trim()}
>
{isNote ? (
<LockIcon aria-hidden="true" data-icon="inline-start" />
) : (
<SendHorizontalIcon aria-hidden="true" data-icon="inline-start" />
)}
{isNote ? "Add note" : "Send reply"}
</Button>
</div>
</form>
</section>
);
}
npx shadcn@latest add @sevenui/component/bubble-11pnpm dlx shadcn@latest add @sevenui/component/bubble-11yarn dlx shadcn@latest add @sevenui/component/bubble-11bunx --bun shadcn@latest add @sevenui/component/bubble-11Mentions
2 new- NO
Nina Okaforinlaunch-plan(unread)
- TB
Tom Beckerininfra(unread)
- AR
Ana Ruizindesign-crit
"use client";
import * as React from "react";
import { AtSignIcon, CornerDownLeftIcon, HashIcon } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
type Mention = {
id: string;
author: string;
initials: string;
channel: string;
time: string;
text: string;
};
const mentions: Mention[] = [
{
id: "m1",
author: "Nina Okafor",
initials: "NO",
channel: "launch-plan",
time: "12m",
text: "@you can you confirm the pricing page copy is final? Legal signs off at 4pm.",
},
{
id: "m2",
author: "Tom Becker",
initials: "TB",
channel: "infra",
time: "1h",
text: "@you the staging database migration is queued behind your PR — ok to rebase?",
},
{
id: "m3",
author: "Ana Ruiz",
initials: "AR",
channel: "design-crit",
time: "3h",
text: "Loved the empty state illustrations, @you. Sharing them with the brand team.",
},
];
export default function Bubble12() {
const [unread, setUnread] = React.useState<Set<string>>(
() => new Set(["m1", "m2"]),
);
const [replies, setReplies] = React.useState<Record<string, string>>({});
const [openId, setOpenId] = React.useState<string | null>(null);
const [draft, setDraft] = React.useState("");
function markRead(id: string) {
setUnread((current) => {
const next = new Set(current);
next.delete(id);
return next;
});
}
function sendReply(id: string) {
const value = draft.trim();
if (!value) return;
setReplies((current) => ({ ...current, [id]: value }));
markRead(id);
setDraft("");
setOpenId(null);
}
return (
<section
aria-labelledby="bubble-12-title"
className="flex w-full max-w-md flex-col rounded-xl border bg-card text-card-foreground"
>
<header className="flex items-center gap-2 border-b px-4 py-3">
<AtSignIcon aria-hidden className="size-4 text-muted-foreground" />
<h2 id="bubble-12-title" className="text-sm font-medium">
Mentions
</h2>
{unread.size > 0 && (
<Badge variant="secondary" className="tabular-nums">
{unread.size} new
</Badge>
)}
<Button
variant="ghost"
size="xs"
className="ml-auto"
disabled={unread.size === 0}
onClick={() => setUnread(new Set())}
>
Mark all read
</Button>
</header>
<ul className="flex flex-col divide-y">
{mentions.map((mention) => {
const isUnread = unread.has(mention.id);
const reply = replies[mention.id];
const isOpen = openId === mention.id;
return (
<li
key={mention.id}
className="flex gap-3 px-4 py-4 data-[unread=true]:bg-muted/40"
data-unread={isUnread}
>
<Avatar size="sm" className="mt-0.5">
<AvatarFallback>{mention.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p className="flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{mention.author}
</span>
in
<span className="inline-flex items-center">
<HashIcon aria-hidden className="size-3" />
{mention.channel}
</span>
<span aria-hidden>·</span>
<time>{mention.time} ago</time>
{isUnread && <span className="sr-only">(unread)</span>}
</p>
<Bubble
variant={isUnread ? "tinted" : "muted"}
className="max-w-full"
>
<BubbleContent className="rounded-2xl rounded-tl-md">
{mention.text}
</BubbleContent>
</Bubble>
{reply && (
<Bubble align="end">
<BubbleContent className="rounded-2xl rounded-br-md">
{reply}
</BubbleContent>
</Bubble>
)}
{isOpen ? (
<form
onSubmit={(event) => {
event.preventDefault();
sendReply(mention.id);
}}
>
<InputGroup>
<InputGroupInput
autoFocus
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setOpenId(null);
}}
placeholder={`Reply in #${mention.channel}`}
aria-label={`Reply to ${mention.author}`}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-xs"
aria-label="Send reply"
>
<CornerDownLeftIcon aria-hidden />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
) : (
!reply && (
<div className="flex gap-1">
<Button
variant="outline"
size="xs"
onClick={() => {
setDraft("");
setOpenId(mention.id);
}}
>
Reply
</Button>
{isUnread && (
<Button
variant="ghost"
size="xs"
onClick={() => markRead(mention.id)}
>
Mark read
</Button>
)}
</div>
)
)}
</div>
</li>
);
})}
</ul>
</section>
);
}
npx shadcn@latest add @sevenui/component/bubble-12pnpm dlx shadcn@latest add @sevenui/component/bubble-12yarn dlx shadcn@latest add @sevenui/component/bubble-12bunx --bun shadcn@latest add @sevenui/component/bubble-12"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { cn } from "cn";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
} from "@/components/ui/message";
const options = [
{ id: "tue", label: "Tuesday, after the design freeze", votes: 5 },
{ id: "thu", label: "Thursday, with the marketing push", votes: 3 },
{ id: "next", label: "Next Monday, after QA sign-off", votes: 2 },
];
export default function Bubble13() {
const [vote, setVote] = React.useState<string | null>(null);
const total = options.reduce((sum, option) => sum + option.votes, 0) + 1;
const hasVoted = vote !== null;
return (
<Message className="w-full max-w-md">
<MessageAvatar>
<Avatar>
<AvatarFallback>PN</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<MessageHeader className="gap-1">
Priya Nair
<span className="font-normal">started a poll</span>
</MessageHeader>
<Bubble variant="outline" className="w-full max-w-80">
<BubbleContent className="flex w-full flex-col gap-3 rounded-2xl p-3">
<p id="bubble-13-question" className="font-medium">
When should we ship v2.4 to everyone?
</p>
<fieldset
aria-labelledby="bubble-13-question"
className="flex min-w-0 flex-col gap-1.5"
>
{options.map((option) => {
const selected = vote === option.id;
const count = option.votes + (selected ? 1 : 0);
const share = hasVoted ? Math.round((count / total) * 100) : 0;
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
aria-label={
hasVoted
? `${option.label}, ${count} votes, ${share}%`
: option.label
}
onClick={() => setVote(option.id)}
className={cn(
"relative flex min-h-10 items-center gap-2 overflow-hidden rounded-xl border px-3 py-2 text-left outline-none transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30",
selected && "border-primary",
)}
>
<span
aria-hidden="true"
style={{ width: `${share}%` }}
className={cn(
"absolute inset-y-0 left-0 transition-[width] duration-500 ease-out motion-reduce:transition-none",
selected ? "bg-primary/15" : "bg-muted",
)}
/>
<span
aria-hidden="true"
className={cn(
"relative flex size-4 shrink-0 items-center justify-center rounded-full border",
selected &&
"border-primary bg-primary text-primary-foreground",
)}
>
{selected ? <CheckIcon className="size-3" /> : null}
</span>
<span className="relative min-w-0 flex-1">
{option.label}
</span>
{hasVoted ? (
<span className="relative text-xs text-muted-foreground tabular-nums">
{share}%
</span>
) : null}
</button>
);
})}
</fieldset>
</BubbleContent>
</Bubble>
<MessageFooter className="gap-2">
<span aria-live="polite" className="tabular-nums">
{hasVoted
? `${total} votes · you can change your vote`
: `${total - 1} votes · closes Friday`}
</span>
{hasVoted ? (
<Button
variant="link"
size="xs"
className="h-auto px-0 text-xs text-muted-foreground"
onClick={() => setVote(null)}
>
Retract vote
</Button>
) : null}
</MessageFooter>
</MessageContent>
</Message>
);
}
npx shadcn@latest add @sevenui/component/bubble-13pnpm dlx shadcn@latest add @sevenui/component/bubble-13yarn dlx shadcn@latest add @sevenui/component/bubble-13bunx --bun shadcn@latest add @sevenui/component/bubble-13- const userId = session.user.id;+ if (!session?.user) return null;+ const userId = session.user.id;
"use client";
import * as React from "react";
import {
BotIcon,
CheckIcon,
CopyIcon,
GitPullRequestIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const errorLog = `TypeError: Cannot read properties of undefined (reading 'id')
at getSession (src/lib/auth.ts:42:28)`;
const patch = `- const userId = session.user.id;
+ if (!session?.user) return null;
+ const userId = session.user.id;`;
type Feedback = "up" | "down" | null;
export default function Bubble14() {
const [copied, setCopied] = React.useState(false);
const [applied, setApplied] = React.useState(false);
const [feedback, setFeedback] = React.useState<Feedback>(null);
React.useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(timeout);
}, [copied]);
async function copyPatch() {
try {
await navigator.clipboard.writeText(patch);
setCopied(true);
} catch {
setCopied(false);
}
}
return (
<section
aria-label="Debug assistant"
className="flex w-full max-w-md flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground"
>
<Bubble align="end" variant="outline" className="max-w-[90%]">
<BubbleContent className="rounded-2xl rounded-br-md">
<p className="mb-2">Login breaks for invited users. Any idea?</p>
<pre className="rounded-lg bg-muted px-2.5 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words text-destructive">
{errorLog}
</pre>
</BubbleContent>
</Bubble>
<div className="flex gap-3">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full border bg-background">
<BotIcon aria-hidden className="size-4" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<Bubble variant="ghost">
<BubbleContent>
Invited users have a session before they finish onboarding, so{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
session.user
</code>{" "}
is still empty. Guard it before reading the id:
</BubbleContent>
</Bubble>
<figure className="overflow-hidden rounded-lg border bg-background">
<figcaption className="flex items-center justify-between gap-2 border-b bg-muted/50 py-1 pr-1 pl-3 font-mono text-xs text-muted-foreground">
<span className="truncate">src/lib/auth.ts</span>
<Button
variant="ghost"
size="icon-xs"
aria-label={copied ? "Copied" : "Copy patch"}
onClick={copyPatch}
>
{copied ? <CheckIcon aria-hidden /> : <CopyIcon aria-hidden />}
</Button>
</figcaption>
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{patch.split("\n").map((line) => (
<span
key={line}
className={
line.startsWith("+")
? "block text-success"
: "block text-destructive"
}
>
{line}
</span>
))}
</pre>
</figure>
<div className="flex flex-wrap items-center gap-1">
<Button
size="sm"
disabled={applied}
onClick={() => setApplied(true)}
>
{applied ? (
<CheckIcon aria-hidden data-icon="inline-start" />
) : (
<GitPullRequestIcon aria-hidden data-icon="inline-start" />
)}
{applied ? "Applied to branch" : "Apply fix"}
</Button>
<fieldset className="min-w-0 ml-auto flex gap-0.5">
<legend className="sr-only">Rate this answer</legend>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Helpful"
aria-pressed={feedback === "up"}
className="aria-pressed:bg-muted aria-pressed:text-foreground"
onClick={() =>
setFeedback(feedback === "up" ? null : "up")
}
/>
}
>
<ThumbsUpIcon aria-hidden />
</TooltipTrigger>
<TooltipContent>Helpful</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Not helpful"
aria-pressed={feedback === "down"}
className="aria-pressed:bg-muted aria-pressed:text-foreground"
onClick={() =>
setFeedback(feedback === "down" ? null : "down")
}
/>
}
>
<ThumbsDownIcon aria-hidden />
</TooltipTrigger>
<TooltipContent>Not helpful</TooltipContent>
</Tooltip>
</fieldset>
</div>
<p
className="text-xs text-muted-foreground empty:hidden"
aria-live="polite"
>
{feedback ? "Thanks — this helps tune future answers." : ""}
</p>
</div>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/bubble-14pnpm dlx shadcn@latest add @sevenui/component/bubble-14yarn dlx shadcn@latest add @sevenui/component/bubble-14bunx --bun shadcn@latest add @sevenui/component/bubble-14"use client";
import * as React from "react";
import {
CheckIcon,
CopyIcon,
EyeIcon,
EyeOffIcon,
KeyRoundIcon,
ShieldCheckIcon,
} from "lucide-react";
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
const secret = "vpn-7Rq4-mezo-K2xa";
const revealSeconds = 20;
type State = "hidden" | "revealed" | "destroyed";
export default function Bubble15() {
const [state, setState] = React.useState<State>("hidden");
const [remaining, setRemaining] = React.useState(revealSeconds);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (state !== "revealed") return;
setRemaining(revealSeconds);
const interval = window.setInterval(() => {
setRemaining((current) => current - 1);
}, 1000);
return () => window.clearInterval(interval);
}, [state]);
React.useEffect(() => {
if (state === "revealed" && remaining <= 0) {
setState("hidden");
setCopied(false);
}
}, [state, remaining]);
function copySecret() {
navigator.clipboard?.writeText(secret).catch(() => {});
setCopied(true);
}
return (
<section
aria-label="IT helpdesk chat"
className="flex w-full max-w-sm flex-col gap-3"
>
<Bubble align="end">
<BubbleContent>
My VPN login stopped working after the password reset.
</BubbleContent>
</Bubble>
<BubbleGroup className="gap-1">
<Bubble variant="muted">
<BubbleContent>
Here is a one-time password. It works for a single sign-in and
expires at 17:30.
</BubbleContent>
</Bubble>
{state === "destroyed" ? (
<Bubble variant="ghost">
<BubbleContent className="flex items-center gap-1.5 text-muted-foreground italic">
<ShieldCheckIcon aria-hidden="true" className="size-4" />
This password was deleted from the chat.
</BubbleContent>
</Bubble>
) : (
<Bubble variant="outline" className="w-full max-w-72">
<BubbleContent className="flex w-full flex-col gap-2.5 rounded-2xl p-3">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KeyRoundIcon aria-hidden="true" className="size-3.5" />
One-time VPN password
</span>
<div className="flex items-center gap-1 rounded-lg bg-muted py-1 pr-1 pl-3">
<span aria-live="polite" className="min-w-0 flex-1 truncate">
{state === "revealed" ? (
<code className="font-mono text-sm">{secret}</code>
) : (
<>
<span
aria-hidden="true"
className="font-mono text-sm tracking-widest text-muted-foreground"
>
••••••••••••
</span>
<span className="sr-only">Password hidden</span>
</>
)}
</span>
{state === "revealed" ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={copied ? "Copied" : "Copy password"}
onClick={copySecret}
>
{copied ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={
state === "revealed" ? "Hide password" : "Reveal password"
}
onClick={() => {
setCopied(false);
setState(state === "revealed" ? "hidden" : "revealed");
}}
>
{state === "revealed" ? (
<EyeOffIcon aria-hidden="true" />
) : (
<EyeIcon aria-hidden="true" />
)}
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{state === "revealed"
? `Hides again in ${remaining}s`
: "Only visible to you"}
</span>
<Button
variant="outline"
size="xs"
onClick={() => setState("destroyed")}
>
I'm signed in, delete it
</Button>
</div>
</BubbleContent>
</Bubble>
)}
</BubbleGroup>
</section>
);
}
npx shadcn@latest add @sevenui/component/bubble-15pnpm dlx shadcn@latest add @sevenui/component/bubble-15yarn dlx shadcn@latest add @sevenui/component/bubble-15bunx --bun shadcn@latest add @sevenui/component/bubble-15launch-week
8 membersToday
"use client";
import * as React from "react";
import {
CheckCheckIcon,
CheckIcon,
DownloadIcon,
FileTextIcon,
HashIcon,
SendHorizontalIcon,
SmilePlusIcon,
} from "lucide-react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/bubble";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
} from "@/components/ui/message";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const reactionChoices = ["👍", "🎉", "👀", "🚀", "❤️"];
type Outgoing = {
id: number;
text: string;
status: "sent" | "seen";
};
export default function Bubble16() {
const [reactions, setReactions] = React.useState<string[]>(["🎉"]);
const [outgoing, setOutgoing] = React.useState<Outgoing[]>([]);
const [draft, setDraft] = React.useState("");
const [typing, setTyping] = React.useState(false);
const [replied, setReplied] = React.useState(false);
const scrollRef = React.useRef<HTMLDivElement>(null);
const nextId = React.useRef(1);
const lastOutgoingId = outgoing.at(-1)?.id;
// Simulate the teammate reading and replying to your first message.
React.useEffect(() => {
if (lastOutgoingId === undefined || replied) return;
const seen = window.setTimeout(() => {
setOutgoing((current) =>
current.map((message) => ({ ...message, status: "seen" })),
);
setTyping(true);
}, 900);
const reply = window.setTimeout(() => {
setTyping(false);
setReplied(true);
}, 2600);
return () => {
window.clearTimeout(seen);
window.clearTimeout(reply);
};
}, [lastOutgoingId, replied]);
React.useEffect(() => {
const node = scrollRef.current;
if (!node) return;
// Keep the log pinned to the newest message: jump there on first
// render, then glide as new messages or the typing indicator arrive.
const initial = outgoing.length === 0 && !typing;
node.scrollTo({
top: node.scrollHeight,
behavior: initial ? "instant" : "smooth",
});
}, [outgoing.length, typing]);
function toggleReaction(emoji: string) {
setReactions((current) =>
current.includes(emoji)
? current.filter((item) => item !== emoji)
: [...current, emoji],
);
}
function send() {
const text = draft.trim();
if (!text) return;
const id = nextId.current++;
setOutgoing((current) => [...current, { id, text, status: "sent" }]);
setDraft("");
}
return (
<section
aria-labelledby="bubble-16-title"
className="flex h-[30rem] w-full max-w-md flex-col overflow-hidden rounded-2xl border bg-background text-foreground shadow-sm"
>
<header className="flex items-center gap-2 border-b bg-card px-4 py-3">
<HashIcon aria-hidden className="size-4 text-muted-foreground" />
<h2 id="bubble-16-title" className="text-sm font-medium">
launch-week
</h2>
<span className="ml-auto text-xs text-muted-foreground">8 members</span>
</header>
<div
ref={scrollRef}
role="log"
aria-label="Messages in launch-week"
className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto scroll-smooth px-3 py-4 motion-reduce:scroll-auto"
>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
Today
<span className="h-px flex-1 bg-border" />
</div>
<Message>
<MessageAvatar>
<Avatar>
<AvatarFallback>JW</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1">
<MessageHeader className="gap-1.5">
<span className="text-foreground">Jade Wu</span>
<time>09:12</time>
</MessageHeader>
<BubbleGroup className="gap-1">
<Bubble variant="muted">
<BubbleContent className="rounded-tl-md">
Final launch checklist is up — we're green on everything
except the status page.
</BubbleContent>
</Bubble>
<Bubble variant="ghost" className="mb-3">
<BubbleContent className="rounded-2xl">
<Attachment>
<AttachmentMedia>
<FileTextIcon aria-hidden />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>launch-checklist.pdf</AttachmentTitle>
<AttachmentDescription>
PDF · 312 KB
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Download launch-checklist.pdf">
<DownloadIcon aria-hidden />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
Can someone own the status page before 3pm?
</BubbleContent>
<BubbleReactions align="start">
{reactions.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={`Remove ${emoji} reaction`}
onClick={() => toggleReaction(emoji)}
className="rounded-full px-1.5 py-0.5 outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
{emoji}
</button>
))}
<Popover>
<PopoverTrigger
aria-label="Add reaction"
className="flex size-6 items-center justify-center rounded-full text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<SmilePlusIcon aria-hidden className="size-3.5" />
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-auto flex-row gap-1 p-1"
>
{reactionChoices.map((emoji) => (
<button
key={emoji}
type="button"
aria-pressed={reactions.includes(emoji)}
aria-label={`React with ${emoji}`}
onClick={() => toggleReaction(emoji)}
className="flex size-8 items-center justify-center rounded-md text-base outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted"
>
{emoji}
</button>
))}
</PopoverContent>
</Popover>
</BubbleReactions>
</Bubble>
</BubbleGroup>
</MessageContent>
</Message>
{outgoing.length > 0 && (
<Message align="end">
<MessageContent className="gap-1">
<BubbleGroup className="gap-1">
{outgoing.map((message) => (
<Bubble key={message.id} align="end">
<BubbleContent className="whitespace-pre-line">
{message.text}
</BubbleContent>
</Bubble>
))}
</BubbleGroup>
<MessageFooter className="gap-1">
{outgoing.at(-1)?.status === "seen" ? (
<>
<CheckCheckIcon
aria-hidden
className="size-3.5 text-primary"
/>
Seen by Jade
</>
) : (
<>
<CheckIcon aria-hidden className="size-3.5" />
Sent
</>
)}
</MessageFooter>
</MessageContent>
</Message>
)}
{(typing || replied) && (
<Message>
<MessageAvatar>
<Avatar>
<AvatarFallback>JW</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant="muted">
{typing ? (
<BubbleContent
aria-label="Jade is typing"
className="flex h-10 items-center gap-1"
>
{[0, 150, 300].map((delay) => (
<span
key={delay}
aria-hidden
className="size-1.5 animate-pulse rounded-full bg-muted-foreground motion-reduce:animate-none"
style={{ animationDelay: `${delay}ms` }}
/>
))}
</BubbleContent>
) : (
<BubbleContent>
Amazing, thank you! Assigning it to you now.
</BubbleContent>
)}
</Bubble>
</MessageContent>
</Message>
)}
</div>
<form
className="border-t bg-card p-3"
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
<InputGroup>
<InputGroupTextarea
rows={1}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
}}
placeholder="Message #launch-week"
aria-label="Message #launch-week"
className="max-h-28 min-h-0"
/>
<InputGroupAddon align="block-end" className="justify-between">
<span className="hidden items-center gap-1 text-xs font-normal min-[360px]:flex">
<Kbd>Shift</Kbd>+<Kbd>Enter</Kbd> for a new line
</span>
<InputGroupButton
type="submit"
variant="default"
size="icon-xs"
className="ml-auto"
aria-label="Send message"
disabled={!draft.trim()}
>
<SendHorizontalIcon aria-hidden />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</section>
);
}
npx shadcn@latest add @sevenui/component/bubble-16pnpm dlx shadcn@latest add @sevenui/component/bubble-16yarn dlx shadcn@latest add @sevenui/component/bubble-16bunx --bun shadcn@latest add @sevenui/component/bubble-16