Message Bubble
A focused conversational surface with visual tones, independent alignment, grouped messages, expandable content, and interactive link or button support.
Animated Surfaces
message-bubble.tsxAn interactive conversation where each newly sent user row pops up once while the assistant streams into a stable row.
"use client";
import {
MessageBubble,
MessageBubbleContent,
MessageBubbleGroup,
} from "@/components/agents/message-bubble";
export function MessageBubbleUsage() {
return (
<MessageBubbleGroup spacing="default">
<MessageBubble align="end" variant="solid">
<MessageBubbleContent>
Can you summarize the release notes?
</MessageBubbleContent>
</MessageBubble>
<MessageBubble align="start" variant="soft">
<MessageBubbleContent>
The release improves streaming, navigation, and recovery states.
</MessageBubbleContent>
</MessageBubble>
</MessageBubbleGroup>
);
}
"use client";
// beui.dev/components/agents/message-bubble
import { ChevronDown } from "lucide-react";
import {
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
type ComponentPropsWithRef,
createContext,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useId,
useState,
} from "react";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_SWAP,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
import { MessageSideContext } from "@/components/agents/message-context";
export type MessageBubbleVariant =
| "solid"
| "soft"
| "tint"
| "outline"
| "ghost"
| "danger";
export type MessageBubbleAlign = "start" | "end";
interface MessageBubbleContextValue {
align?: MessageBubbleAlign;
animateIn: boolean;
variant: MessageBubbleVariant;
}
const MessageBubbleContext = createContext<MessageBubbleContextValue>({
animateIn: true,
variant: "soft",
});
const MessageBubbleLayoutContext = createContext<() => void>(() => {});
export interface MessageBubbleProps
extends Omit<HTMLMotionProps<"div">, "children"> {
variant?: MessageBubbleVariant;
/** Defaults to the surrounding Message alignment when omitted. */
align?: MessageBubbleAlign;
/** Plays the bubble entrance once when this component mounts. */
animateIn?: boolean;
children?: ReactNode;
}
export interface MessageBubbleContentProps
extends ComponentPropsWithRef<"div"> {
/** Replaces the content element while preserving bubble styling. */
render?: ReactElement;
}
export interface MessageBubbleGroupProps extends ComponentPropsWithRef<"div"> {
spacing?: "compact" | "default";
}
export interface MessageBubbleCollapsibleProps
extends ComponentPropsWithRef<"div"> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapsedLines?: 2 | 3 | 4 | 5 | 6;
moreLabel?: ReactNode;
lessLabel?: ReactNode;
contentClassName?: string;
triggerClassName?: string;
children?: ReactNode;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
const BUBBLE_CONTENT_REVEAL = {
duration: 0.12,
ease: EASE_OUT,
delay: 0.04,
} as const;
// Sent bubbles should pop into place quickly with one restrained overshoot.
const BUBBLE_POP = {
type: "spring",
stiffness: 520,
damping: 27,
mass: 0.52,
} as const;
export function MessageBubble({
variant = "soft",
align,
animateIn = false,
className,
children,
initial,
animate,
exit,
transition,
layout,
...props
}: MessageBubbleProps) {
const reduce = useReducedMotion() ?? false;
const messageSide = useContext(MessageSideContext);
const resolvedAlign = align ?? messageSide ?? "start";
return (
<MessageBubbleContext.Provider
value={{ align: resolvedAlign, animateIn, variant }}
>
<motion.div
data-slot="message-bubble"
data-align={resolvedAlign}
data-variant={variant}
layout={layout}
initial={initial ?? false}
animate={animate}
exit={
exit ??
(reduce ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.99 })
}
transition={transition ?? (reduce ? { duration: 0.12 } : SPRING_LAYOUT)}
className={cn(
"group/bubble flex w-full flex-col",
resolvedAlign === "end" ? "items-end" : "items-start",
className,
)}
{...props}
>
{children}
</motion.div>
</MessageBubbleContext.Provider>
);
}
function bubbleContentClass(
variant: MessageBubbleVariant,
interactive: boolean,
) {
return cn(
"relative z-0 min-w-9 max-w-[82%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 text-foreground",
"[&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-background/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-background/60 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
variant === "solid" && "text-background",
variant === "ghost" && "w-full max-w-none rounded-none px-0 py-0",
variant === "danger" && "text-destructive",
interactive &&
"cursor-pointer text-left outline-none transition-[background-color,color,transform] duration-150 hover:brightness-[0.98] focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99]",
);
}
function bubbleSurfaceClass(
variant: MessageBubbleVariant,
align: MessageBubbleAlign,
) {
return cn(
"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]",
align === "end" ? "origin-bottom-right" : "origin-bottom-left",
variant === "solid" && "bg-foreground",
variant === "soft" && "bg-muted",
variant === "tint" && "bg-primary/10",
variant === "outline" && "border border-border/70 bg-background",
variant === "danger" && "bg-destructive/10",
);
}
export function MessageBubbleContent({
render,
className,
children,
ref,
...props
}: MessageBubbleContentProps) {
const reduce = useReducedMotion() ?? false;
const { align = "start", animateIn, variant } =
useContext(MessageBubbleContext);
const [layoutVersion, setLayoutVersion] = useState(0);
const notifyLayout = useCallback(
() => setLayoutVersion((version) => version + 1),
[],
);
const interactive =
render?.type === "button" || render?.type === "a";
const classes = cn(bubbleContentClass(variant, interactive), className);
const composedChildren = (
<>
{variant !== "ghost" ? (
<motion.span
aria-hidden="true"
layout={reduce ? false : "size"}
layoutDependency={layoutVersion}
initial={
animateIn && !reduce
? {
opacity: 0,
scale: 0.92,
}
: false
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.12, ease: EASE_OUT },
scale: BUBBLE_POP,
layout: SPRING_LAYOUT,
}
}
className={bubbleSurfaceClass(variant, align)}
/>
) : null}
<MessageBubbleLayoutContext.Provider value={notifyLayout}>
<motion.div
initial={
animateIn
? reduce
? { opacity: 0 }
: { opacity: 0 }
: false
}
animate={{ opacity: 1 }}
transition={
reduce ? { duration: 0.12, ease: EASE_OUT } : BUBBLE_CONTENT_REVEAL
}
className="relative"
>
{children}
</motion.div>
</MessageBubbleLayoutContext.Provider>
</>
);
if (render) {
const child = render as ReactElement<
Record<string, unknown> & { className?: string; ref?: Ref<HTMLElement> }
>;
return cloneElement(child, {
...props,
ref: mergeRefs(child.props.ref, ref as Ref<HTMLElement> | undefined),
className: cn(classes, child.props.className),
children: composedChildren,
"data-slot": "message-bubble-content",
});
}
return (
<div
ref={ref}
data-slot="message-bubble-content"
className={classes}
{...props}
>
{composedChildren}
</div>
);
}
export function MessageBubbleGroup({
spacing = "compact",
className,
...props
}: MessageBubbleGroupProps) {
return (
<div
data-slot="message-bubble-group"
className={cn(
"flex w-full flex-col",
spacing === "compact" ? "gap-1.5" : "gap-3",
className,
)}
{...props}
/>
);
}
const LINE_CLAMP_CLASS = {
2: "line-clamp-2",
3: "line-clamp-3",
4: "line-clamp-4",
5: "line-clamp-5",
6: "line-clamp-6",
} as const;
export function MessageBubbleCollapsible({
open,
defaultOpen = false,
onOpenChange,
collapsedLines = 4,
moreLabel = "Show more",
lessLabel = "Show less",
contentClassName,
triggerClassName,
className,
children,
...props
}: MessageBubbleCollapsibleProps) {
const reduce = useReducedMotion() ?? false;
const contentId = useId();
const notifyLayout = useContext(MessageBubbleLayoutContext);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
notifyLayout();
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[notifyLayout, onOpenChange, open],
);
return (
<div
data-slot="message-bubble-collapsible"
data-state={currentOpen ? "open" : "closed"}
className={cn("w-full", className)}
{...props}
>
<div
id={contentId}
className={cn(
"transition-[mask-image] duration-200",
!currentOpen && LINE_CLAMP_CLASS[collapsedLines],
!currentOpen &&
"[mask-image:linear-gradient(to_bottom,#000_68%,transparent_100%)]",
contentClassName,
)}
>
{children}
</div>
<button
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className={cn(
"mt-2 inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
triggerClassName,
)}
>
<span>{currentOpen ? lessLabel : moreLabel}</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
</div>
);
}
With Avatars
message-bubble.tsxProgressively arriving messages with sender avatars, metadata, delivery state, and grouped follow-up alignment.
"use client";
import { Bot, RotateCcw, User } from "lucide-react";
import { AnimatePresence } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
MessageBubble,
MessageBubbleContent,
} from "@/components/agents/message-bubble";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
} from "@/components/agents/message";
import { StreamingResponse } from "@/components/agents/streaming-response";
export function MessageBubbleAvatarsPreview() {
const timer = useRef<number | undefined>(undefined);
const [run, setRun] = useState(0);
const [step, setStep] = useState(0);
const replay = useCallback(() => {
if (timer.current) window.clearTimeout(timer.current);
setStep(0);
setRun((value) => value + 1);
}, []);
useEffect(() => {
if (step >= 2) return;
timer.current = window.setTimeout(
() => setStep((value) => value + 1),
step === 0 ? 650 : 900,
);
return () => {
if (timer.current) window.clearTimeout(timer.current);
};
}, [step]);
return (
<div className="flex h-[410px] w-full max-w-xl flex-col justify-center px-3">
<MessageGroup key={run} spacing="default">
<Message from="user">
<MessageAvatar className="bg-foreground text-background">
<User />
</MessageAvatar>
<MessageContent>
<MessageHeader>
<span className="font-medium text-foreground/70">You</span>
<span>Now</span>
</MessageHeader>
<MessageBubble variant="solid">
<MessageBubbleContent>
Can you turn these notes into a launch update?
</MessageBubbleContent>
</MessageBubble>
<MessageFooter>Delivered</MessageFooter>
</MessageContent>
</Message>
<AnimatePresence mode="popLayout">
{step >= 1 ? (
<Message key="draft" from="assistant">
<MessageAvatar>
<Bot />
</MessageAvatar>
<MessageContent>
<MessageHeader>
<span className="font-medium text-foreground/70">Assistant</span>
<span>Just now</span>
</MessageHeader>
<MessageBubble variant="soft">
<MessageBubbleContent>
<StreamingResponse status="complete" showActions={false}>
Absolutely. I’ll keep it concise and lead with what changed.
</StreamingResponse>
</MessageBubbleContent>
</MessageBubble>
</MessageContent>
</Message>
) : null}
{step >= 2 ? (
<Message key="follow-up" from="assistant">
<MessageAvatar placeholder />
<MessageContent>
<MessageBubble variant="soft">
<MessageBubbleContent>
<StreamingResponse status="complete" showActions={false}>
Do you want the tone to feel more technical or more customer-facing?
</StreamingResponse>
</MessageBubbleContent>
</MessageBubble>
</MessageContent>
</Message>
) : null}
</AnimatePresence>
</MessageGroup>
<div className="flex h-11 items-end justify-center">
<button
type="button"
onClick={replay}
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-border px-3 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3.5" />
Replay
</button>
</div>
</div>
);
}
"use client";
// beui.dev/components/agents/message-bubble
import { ChevronDown } from "lucide-react";
import {
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
type ComponentPropsWithRef,
createContext,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useId,
useState,
} from "react";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_SWAP,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
import { MessageSideContext } from "@/components/agents/message-context";
export type MessageBubbleVariant =
| "solid"
| "soft"
| "tint"
| "outline"
| "ghost"
| "danger";
export type MessageBubbleAlign = "start" | "end";
interface MessageBubbleContextValue {
align?: MessageBubbleAlign;
animateIn: boolean;
variant: MessageBubbleVariant;
}
const MessageBubbleContext = createContext<MessageBubbleContextValue>({
animateIn: true,
variant: "soft",
});
const MessageBubbleLayoutContext = createContext<() => void>(() => {});
export interface MessageBubbleProps
extends Omit<HTMLMotionProps<"div">, "children"> {
variant?: MessageBubbleVariant;
/** Defaults to the surrounding Message alignment when omitted. */
align?: MessageBubbleAlign;
/** Plays the bubble entrance once when this component mounts. */
animateIn?: boolean;
children?: ReactNode;
}
export interface MessageBubbleContentProps
extends ComponentPropsWithRef<"div"> {
/** Replaces the content element while preserving bubble styling. */
render?: ReactElement;
}
export interface MessageBubbleGroupProps extends ComponentPropsWithRef<"div"> {
spacing?: "compact" | "default";
}
export interface MessageBubbleCollapsibleProps
extends ComponentPropsWithRef<"div"> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapsedLines?: 2 | 3 | 4 | 5 | 6;
moreLabel?: ReactNode;
lessLabel?: ReactNode;
contentClassName?: string;
triggerClassName?: string;
children?: ReactNode;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
const BUBBLE_CONTENT_REVEAL = {
duration: 0.12,
ease: EASE_OUT,
delay: 0.04,
} as const;
// Sent bubbles should pop into place quickly with one restrained overshoot.
const BUBBLE_POP = {
type: "spring",
stiffness: 520,
damping: 27,
mass: 0.52,
} as const;
export function MessageBubble({
variant = "soft",
align,
animateIn = false,
className,
children,
initial,
animate,
exit,
transition,
layout,
...props
}: MessageBubbleProps) {
const reduce = useReducedMotion() ?? false;
const messageSide = useContext(MessageSideContext);
const resolvedAlign = align ?? messageSide ?? "start";
return (
<MessageBubbleContext.Provider
value={{ align: resolvedAlign, animateIn, variant }}
>
<motion.div
data-slot="message-bubble"
data-align={resolvedAlign}
data-variant={variant}
layout={layout}
initial={initial ?? false}
animate={animate}
exit={
exit ??
(reduce ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.99 })
}
transition={transition ?? (reduce ? { duration: 0.12 } : SPRING_LAYOUT)}
className={cn(
"group/bubble flex w-full flex-col",
resolvedAlign === "end" ? "items-end" : "items-start",
className,
)}
{...props}
>
{children}
</motion.div>
</MessageBubbleContext.Provider>
);
}
function bubbleContentClass(
variant: MessageBubbleVariant,
interactive: boolean,
) {
return cn(
"relative z-0 min-w-9 max-w-[82%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 text-foreground",
"[&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-background/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-background/60 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
variant === "solid" && "text-background",
variant === "ghost" && "w-full max-w-none rounded-none px-0 py-0",
variant === "danger" && "text-destructive",
interactive &&
"cursor-pointer text-left outline-none transition-[background-color,color,transform] duration-150 hover:brightness-[0.98] focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99]",
);
}
function bubbleSurfaceClass(
variant: MessageBubbleVariant,
align: MessageBubbleAlign,
) {
return cn(
"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]",
align === "end" ? "origin-bottom-right" : "origin-bottom-left",
variant === "solid" && "bg-foreground",
variant === "soft" && "bg-muted",
variant === "tint" && "bg-primary/10",
variant === "outline" && "border border-border/70 bg-background",
variant === "danger" && "bg-destructive/10",
);
}
export function MessageBubbleContent({
render,
className,
children,
ref,
...props
}: MessageBubbleContentProps) {
const reduce = useReducedMotion() ?? false;
const { align = "start", animateIn, variant } =
useContext(MessageBubbleContext);
const [layoutVersion, setLayoutVersion] = useState(0);
const notifyLayout = useCallback(
() => setLayoutVersion((version) => version + 1),
[],
);
const interactive =
render?.type === "button" || render?.type === "a";
const classes = cn(bubbleContentClass(variant, interactive), className);
const composedChildren = (
<>
{variant !== "ghost" ? (
<motion.span
aria-hidden="true"
layout={reduce ? false : "size"}
layoutDependency={layoutVersion}
initial={
animateIn && !reduce
? {
opacity: 0,
scale: 0.92,
}
: false
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.12, ease: EASE_OUT },
scale: BUBBLE_POP,
layout: SPRING_LAYOUT,
}
}
className={bubbleSurfaceClass(variant, align)}
/>
) : null}
<MessageBubbleLayoutContext.Provider value={notifyLayout}>
<motion.div
initial={
animateIn
? reduce
? { opacity: 0 }
: { opacity: 0 }
: false
}
animate={{ opacity: 1 }}
transition={
reduce ? { duration: 0.12, ease: EASE_OUT } : BUBBLE_CONTENT_REVEAL
}
className="relative"
>
{children}
</motion.div>
</MessageBubbleLayoutContext.Provider>
</>
);
if (render) {
const child = render as ReactElement<
Record<string, unknown> & { className?: string; ref?: Ref<HTMLElement> }
>;
return cloneElement(child, {
...props,
ref: mergeRefs(child.props.ref, ref as Ref<HTMLElement> | undefined),
className: cn(classes, child.props.className),
children: composedChildren,
"data-slot": "message-bubble-content",
});
}
return (
<div
ref={ref}
data-slot="message-bubble-content"
className={classes}
{...props}
>
{composedChildren}
</div>
);
}
export function MessageBubbleGroup({
spacing = "compact",
className,
...props
}: MessageBubbleGroupProps) {
return (
<div
data-slot="message-bubble-group"
className={cn(
"flex w-full flex-col",
spacing === "compact" ? "gap-1.5" : "gap-3",
className,
)}
{...props}
/>
);
}
const LINE_CLAMP_CLASS = {
2: "line-clamp-2",
3: "line-clamp-3",
4: "line-clamp-4",
5: "line-clamp-5",
6: "line-clamp-6",
} as const;
export function MessageBubbleCollapsible({
open,
defaultOpen = false,
onOpenChange,
collapsedLines = 4,
moreLabel = "Show more",
lessLabel = "Show less",
contentClassName,
triggerClassName,
className,
children,
...props
}: MessageBubbleCollapsibleProps) {
const reduce = useReducedMotion() ?? false;
const contentId = useId();
const notifyLayout = useContext(MessageBubbleLayoutContext);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
notifyLayout();
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[notifyLayout, onOpenChange, open],
);
return (
<div
data-slot="message-bubble-collapsible"
data-state={currentOpen ? "open" : "closed"}
className={cn("w-full", className)}
{...props}
>
<div
id={contentId}
className={cn(
"transition-[mask-image] duration-200",
!currentOpen && LINE_CLAMP_CLASS[collapsedLines],
!currentOpen &&
"[mask-image:linear-gradient(to_bottom,#000_68%,transparent_100%)]",
contentClassName,
)}
>
{children}
</div>
<button
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className={cn(
"mt-2 inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
triggerClassName,
)}
>
<span>{currentOpen ? lessLabel : moreLabel}</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
</div>
);
}
Show More
message-bubble.tsxA long assistant message with a compact line-clamped preview and animated disclosure control.
"use client";
import { Bot } from "lucide-react";
import {
MessageBubble,
MessageBubbleCollapsible,
MessageBubbleContent,
} from "@/components/agents/message-bubble";
import {
Message,
MessageAvatar,
MessageContent,
MessageHeader,
} from "@/components/agents/message";
import { StreamingResponse } from "@/components/agents/streaming-response";
export function MessageBubbleCollapsiblePreview() {
return (
<div className="flex h-[410px] w-full max-w-xl items-start px-3 pt-14">
<Message from="assistant">
<MessageAvatar>
<Bot />
</MessageAvatar>
<MessageContent>
<MessageHeader>
<span className="font-medium text-foreground/70">Assistant</span>
<span>Summary</span>
</MessageHeader>
<MessageBubble variant="soft" animateIn={false}>
<MessageBubbleContent className="max-w-[90%]">
<StreamingResponse status="complete" showActions={false}>
<MessageBubbleCollapsible collapsedLines={4}>
<p>
The release is ready for a focused rollout. The main conversation flow,
keyboard navigation, error recovery, and reduced-motion behavior are all
covered.
</p>
<p>
I would keep advanced workflow controls out of this version. They add
configuration without improving the first-run experience, and the usage
data from this release will give us a better basis for those decisions.
</p>
<p>
Before publishing, run the accessibility suite once more and verify the
streaming behavior with a long response on a smaller viewport.
</p>
</MessageBubbleCollapsible>
</StreamingResponse>
</MessageBubbleContent>
</MessageBubble>
</MessageContent>
</Message>
</div>
);
}
"use client";
// beui.dev/components/agents/message-bubble
import { ChevronDown } from "lucide-react";
import {
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
type ComponentPropsWithRef,
createContext,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useId,
useState,
} from "react";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_SWAP,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
import { MessageSideContext } from "@/components/agents/message-context";
export type MessageBubbleVariant =
| "solid"
| "soft"
| "tint"
| "outline"
| "ghost"
| "danger";
export type MessageBubbleAlign = "start" | "end";
interface MessageBubbleContextValue {
align?: MessageBubbleAlign;
animateIn: boolean;
variant: MessageBubbleVariant;
}
const MessageBubbleContext = createContext<MessageBubbleContextValue>({
animateIn: true,
variant: "soft",
});
const MessageBubbleLayoutContext = createContext<() => void>(() => {});
export interface MessageBubbleProps
extends Omit<HTMLMotionProps<"div">, "children"> {
variant?: MessageBubbleVariant;
/** Defaults to the surrounding Message alignment when omitted. */
align?: MessageBubbleAlign;
/** Plays the bubble entrance once when this component mounts. */
animateIn?: boolean;
children?: ReactNode;
}
export interface MessageBubbleContentProps
extends ComponentPropsWithRef<"div"> {
/** Replaces the content element while preserving bubble styling. */
render?: ReactElement;
}
export interface MessageBubbleGroupProps extends ComponentPropsWithRef<"div"> {
spacing?: "compact" | "default";
}
export interface MessageBubbleCollapsibleProps
extends ComponentPropsWithRef<"div"> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapsedLines?: 2 | 3 | 4 | 5 | 6;
moreLabel?: ReactNode;
lessLabel?: ReactNode;
contentClassName?: string;
triggerClassName?: string;
children?: ReactNode;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
const BUBBLE_CONTENT_REVEAL = {
duration: 0.12,
ease: EASE_OUT,
delay: 0.04,
} as const;
// Sent bubbles should pop into place quickly with one restrained overshoot.
const BUBBLE_POP = {
type: "spring",
stiffness: 520,
damping: 27,
mass: 0.52,
} as const;
export function MessageBubble({
variant = "soft",
align,
animateIn = false,
className,
children,
initial,
animate,
exit,
transition,
layout,
...props
}: MessageBubbleProps) {
const reduce = useReducedMotion() ?? false;
const messageSide = useContext(MessageSideContext);
const resolvedAlign = align ?? messageSide ?? "start";
return (
<MessageBubbleContext.Provider
value={{ align: resolvedAlign, animateIn, variant }}
>
<motion.div
data-slot="message-bubble"
data-align={resolvedAlign}
data-variant={variant}
layout={layout}
initial={initial ?? false}
animate={animate}
exit={
exit ??
(reduce ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.99 })
}
transition={transition ?? (reduce ? { duration: 0.12 } : SPRING_LAYOUT)}
className={cn(
"group/bubble flex w-full flex-col",
resolvedAlign === "end" ? "items-end" : "items-start",
className,
)}
{...props}
>
{children}
</motion.div>
</MessageBubbleContext.Provider>
);
}
function bubbleContentClass(
variant: MessageBubbleVariant,
interactive: boolean,
) {
return cn(
"relative z-0 min-w-9 max-w-[82%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 text-foreground",
"[&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-background/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-background/60 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
variant === "solid" && "text-background",
variant === "ghost" && "w-full max-w-none rounded-none px-0 py-0",
variant === "danger" && "text-destructive",
interactive &&
"cursor-pointer text-left outline-none transition-[background-color,color,transform] duration-150 hover:brightness-[0.98] focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99]",
);
}
function bubbleSurfaceClass(
variant: MessageBubbleVariant,
align: MessageBubbleAlign,
) {
return cn(
"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]",
align === "end" ? "origin-bottom-right" : "origin-bottom-left",
variant === "solid" && "bg-foreground",
variant === "soft" && "bg-muted",
variant === "tint" && "bg-primary/10",
variant === "outline" && "border border-border/70 bg-background",
variant === "danger" && "bg-destructive/10",
);
}
export function MessageBubbleContent({
render,
className,
children,
ref,
...props
}: MessageBubbleContentProps) {
const reduce = useReducedMotion() ?? false;
const { align = "start", animateIn, variant } =
useContext(MessageBubbleContext);
const [layoutVersion, setLayoutVersion] = useState(0);
const notifyLayout = useCallback(
() => setLayoutVersion((version) => version + 1),
[],
);
const interactive =
render?.type === "button" || render?.type === "a";
const classes = cn(bubbleContentClass(variant, interactive), className);
const composedChildren = (
<>
{variant !== "ghost" ? (
<motion.span
aria-hidden="true"
layout={reduce ? false : "size"}
layoutDependency={layoutVersion}
initial={
animateIn && !reduce
? {
opacity: 0,
scale: 0.92,
}
: false
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.12, ease: EASE_OUT },
scale: BUBBLE_POP,
layout: SPRING_LAYOUT,
}
}
className={bubbleSurfaceClass(variant, align)}
/>
) : null}
<MessageBubbleLayoutContext.Provider value={notifyLayout}>
<motion.div
initial={
animateIn
? reduce
? { opacity: 0 }
: { opacity: 0 }
: false
}
animate={{ opacity: 1 }}
transition={
reduce ? { duration: 0.12, ease: EASE_OUT } : BUBBLE_CONTENT_REVEAL
}
className="relative"
>
{children}
</motion.div>
</MessageBubbleLayoutContext.Provider>
</>
);
if (render) {
const child = render as ReactElement<
Record<string, unknown> & { className?: string; ref?: Ref<HTMLElement> }
>;
return cloneElement(child, {
...props,
ref: mergeRefs(child.props.ref, ref as Ref<HTMLElement> | undefined),
className: cn(classes, child.props.className),
children: composedChildren,
"data-slot": "message-bubble-content",
});
}
return (
<div
ref={ref}
data-slot="message-bubble-content"
className={classes}
{...props}
>
{composedChildren}
</div>
);
}
export function MessageBubbleGroup({
spacing = "compact",
className,
...props
}: MessageBubbleGroupProps) {
return (
<div
data-slot="message-bubble-group"
className={cn(
"flex w-full flex-col",
spacing === "compact" ? "gap-1.5" : "gap-3",
className,
)}
{...props}
/>
);
}
const LINE_CLAMP_CLASS = {
2: "line-clamp-2",
3: "line-clamp-3",
4: "line-clamp-4",
5: "line-clamp-5",
6: "line-clamp-6",
} as const;
export function MessageBubbleCollapsible({
open,
defaultOpen = false,
onOpenChange,
collapsedLines = 4,
moreLabel = "Show more",
lessLabel = "Show less",
contentClassName,
triggerClassName,
className,
children,
...props
}: MessageBubbleCollapsibleProps) {
const reduce = useReducedMotion() ?? false;
const contentId = useId();
const notifyLayout = useContext(MessageBubbleLayoutContext);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
notifyLayout();
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[notifyLayout, onOpenChange, open],
);
return (
<div
data-slot="message-bubble-collapsible"
data-state={currentOpen ? "open" : "closed"}
className={cn("w-full", className)}
{...props}
>
<div
id={contentId}
className={cn(
"transition-[mask-image] duration-200",
!currentOpen && LINE_CLAMP_CLASS[collapsedLines],
!currentOpen &&
"[mask-image:linear-gradient(to_bottom,#000_68%,transparent_100%)]",
contentClassName,
)}
>
{children}
</div>
<button
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className={cn(
"mt-2 inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
triggerClassName,
)}
>
<span>{currentOpen ? lessLabel : moreLabel}</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
</div>
);
}
API Reference
MessageBubble
variant?"ghost" | "outline" | "solid" | "danger" | "soft" | "tint"softalign?"end" | "start"Defaults to the surrounding Message alignment when omitted.
—animateIn?booleanPlays the bubble entrance once when this component mounts.
falseclassName?string—MessageBubbleContent
render?ReactElement<unknown, string | JSXElementConstructor<any>>Replaces the content element while preserving bubble styling.
—className?string—MessageBubbleGroup
spacing?"default" | "compact"compactclassName?string—MessageBubbleCollapsible
open?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—collapsedLines?4 | 2 | 3 | 5 | 64moreLabel?ReactNodeShow morelessLabel?ReactNodeShow lesscontentClassName?string—triggerClassName?string—className?string—Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
/** Resolve a website URL to its conventional root favicon location. */
export function getFaviconUrl(value: string) {
try {
return new URL("/favicon.ico", value).toString();
} catch {
return null;
}
}
Copy the source code
"use client";
// beui.dev/components/agents/message-bubble
import { ChevronDown } from "lucide-react";
import {
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
type ComponentPropsWithRef,
createContext,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useId,
useState,
} from "react";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_SWAP,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
import { MessageSideContext } from "@/components/agents/message-context";
export type MessageBubbleVariant =
| "solid"
| "soft"
| "tint"
| "outline"
| "ghost"
| "danger";
export type MessageBubbleAlign = "start" | "end";
interface MessageBubbleContextValue {
align?: MessageBubbleAlign;
animateIn: boolean;
variant: MessageBubbleVariant;
}
const MessageBubbleContext = createContext<MessageBubbleContextValue>({
animateIn: true,
variant: "soft",
});
const MessageBubbleLayoutContext = createContext<() => void>(() => {});
export interface MessageBubbleProps
extends Omit<HTMLMotionProps<"div">, "children"> {
variant?: MessageBubbleVariant;
/** Defaults to the surrounding Message alignment when omitted. */
align?: MessageBubbleAlign;
/** Plays the bubble entrance once when this component mounts. */
animateIn?: boolean;
children?: ReactNode;
}
export interface MessageBubbleContentProps
extends ComponentPropsWithRef<"div"> {
/** Replaces the content element while preserving bubble styling. */
render?: ReactElement;
}
export interface MessageBubbleGroupProps extends ComponentPropsWithRef<"div"> {
spacing?: "compact" | "default";
}
export interface MessageBubbleCollapsibleProps
extends ComponentPropsWithRef<"div"> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapsedLines?: 2 | 3 | 4 | 5 | 6;
moreLabel?: ReactNode;
lessLabel?: ReactNode;
contentClassName?: string;
triggerClassName?: string;
children?: ReactNode;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
const BUBBLE_CONTENT_REVEAL = {
duration: 0.12,
ease: EASE_OUT,
delay: 0.04,
} as const;
// Sent bubbles should pop into place quickly with one restrained overshoot.
const BUBBLE_POP = {
type: "spring",
stiffness: 520,
damping: 27,
mass: 0.52,
} as const;
export function MessageBubble({
variant = "soft",
align,
animateIn = false,
className,
children,
initial,
animate,
exit,
transition,
layout,
...props
}: MessageBubbleProps) {
const reduce = useReducedMotion() ?? false;
const messageSide = useContext(MessageSideContext);
const resolvedAlign = align ?? messageSide ?? "start";
return (
<MessageBubbleContext.Provider
value={{ align: resolvedAlign, animateIn, variant }}
>
<motion.div
data-slot="message-bubble"
data-align={resolvedAlign}
data-variant={variant}
layout={layout}
initial={initial ?? false}
animate={animate}
exit={
exit ??
(reduce ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.99 })
}
transition={transition ?? (reduce ? { duration: 0.12 } : SPRING_LAYOUT)}
className={cn(
"group/bubble flex w-full flex-col",
resolvedAlign === "end" ? "items-end" : "items-start",
className,
)}
{...props}
>
{children}
</motion.div>
</MessageBubbleContext.Provider>
);
}
function bubbleContentClass(
variant: MessageBubbleVariant,
interactive: boolean,
) {
return cn(
"relative z-0 min-w-9 max-w-[82%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 text-foreground",
"[&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-background/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-background/60 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
variant === "solid" && "text-background",
variant === "ghost" && "w-full max-w-none rounded-none px-0 py-0",
variant === "danger" && "text-destructive",
interactive &&
"cursor-pointer text-left outline-none transition-[background-color,color,transform] duration-150 hover:brightness-[0.98] focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99]",
);
}
function bubbleSurfaceClass(
variant: MessageBubbleVariant,
align: MessageBubbleAlign,
) {
return cn(
"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]",
align === "end" ? "origin-bottom-right" : "origin-bottom-left",
variant === "solid" && "bg-foreground",
variant === "soft" && "bg-muted",
variant === "tint" && "bg-primary/10",
variant === "outline" && "border border-border/70 bg-background",
variant === "danger" && "bg-destructive/10",
);
}
export function MessageBubbleContent({
render,
className,
children,
ref,
...props
}: MessageBubbleContentProps) {
const reduce = useReducedMotion() ?? false;
const { align = "start", animateIn, variant } =
useContext(MessageBubbleContext);
const [layoutVersion, setLayoutVersion] = useState(0);
const notifyLayout = useCallback(
() => setLayoutVersion((version) => version + 1),
[],
);
const interactive =
render?.type === "button" || render?.type === "a";
const classes = cn(bubbleContentClass(variant, interactive), className);
const composedChildren = (
<>
{variant !== "ghost" ? (
<motion.span
aria-hidden="true"
layout={reduce ? false : "size"}
layoutDependency={layoutVersion}
initial={
animateIn && !reduce
? {
opacity: 0,
scale: 0.92,
}
: false
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.12, ease: EASE_OUT },
scale: BUBBLE_POP,
layout: SPRING_LAYOUT,
}
}
className={bubbleSurfaceClass(variant, align)}
/>
) : null}
<MessageBubbleLayoutContext.Provider value={notifyLayout}>
<motion.div
initial={
animateIn
? reduce
? { opacity: 0 }
: { opacity: 0 }
: false
}
animate={{ opacity: 1 }}
transition={
reduce ? { duration: 0.12, ease: EASE_OUT } : BUBBLE_CONTENT_REVEAL
}
className="relative"
>
{children}
</motion.div>
</MessageBubbleLayoutContext.Provider>
</>
);
if (render) {
const child = render as ReactElement<
Record<string, unknown> & { className?: string; ref?: Ref<HTMLElement> }
>;
return cloneElement(child, {
...props,
ref: mergeRefs(child.props.ref, ref as Ref<HTMLElement> | undefined),
className: cn(classes, child.props.className),
children: composedChildren,
"data-slot": "message-bubble-content",
});
}
return (
<div
ref={ref}
data-slot="message-bubble-content"
className={classes}
{...props}
>
{composedChildren}
</div>
);
}
export function MessageBubbleGroup({
spacing = "compact",
className,
...props
}: MessageBubbleGroupProps) {
return (
<div
data-slot="message-bubble-group"
className={cn(
"flex w-full flex-col",
spacing === "compact" ? "gap-1.5" : "gap-3",
className,
)}
{...props}
/>
);
}
const LINE_CLAMP_CLASS = {
2: "line-clamp-2",
3: "line-clamp-3",
4: "line-clamp-4",
5: "line-clamp-5",
6: "line-clamp-6",
} as const;
export function MessageBubbleCollapsible({
open,
defaultOpen = false,
onOpenChange,
collapsedLines = 4,
moreLabel = "Show more",
lessLabel = "Show less",
contentClassName,
triggerClassName,
className,
children,
...props
}: MessageBubbleCollapsibleProps) {
const reduce = useReducedMotion() ?? false;
const contentId = useId();
const notifyLayout = useContext(MessageBubbleLayoutContext);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
notifyLayout();
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[notifyLayout, onOpenChange, open],
);
return (
<div
data-slot="message-bubble-collapsible"
data-state={currentOpen ? "open" : "closed"}
className={cn("w-full", className)}
{...props}
>
<div
id={contentId}
className={cn(
"transition-[mask-image] duration-200",
!currentOpen && LINE_CLAMP_CLASS[collapsedLines],
!currentOpen &&
"[mask-image:linear-gradient(to_bottom,#000_68%,transparent_100%)]",
contentClassName,
)}
>
{children}
</div>
<button
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className={cn(
"mt-2 inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
triggerClassName,
)}
>
<span>{currentOpen ? lessLabel : moreLabel}</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
</div>
);
}
"use client";
import { createContext } from "react";
export type MessageSide = "start" | "end";
export const MessageSideContext = createContext<MessageSide | undefined>(
undefined,
);
"use client";
import { Bot, User } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
MessageTyping,
} from "@/components/agents/message";
import {
MessageBubble,
MessageBubbleContent,
type MessageBubbleVariant,
} from "@/components/agents/message-bubble";
import { MessageScroller } from "@/components/agents/message-scroller";
import { PromptInput } from "@/components/agents/prompt-input";
import { StreamingResponse } from "@/components/agents/streaming-response";
import { cn } from "@/lib/utils";
export interface ChatPreviewMessage {
id: string;
from: "user" | "assistant";
content: string;
}
interface RenderedChatMessage extends ChatPreviewMessage {
animateIn: boolean;
streaming?: boolean;
}
export interface ChatPreviewProps {
initialMessages?: ChatPreviewMessage[];
reply?: string | ((prompt: string) => string);
showAvatars?: boolean;
showMetadata?: boolean;
showRail?: boolean;
assistantVariant?: MessageBubbleVariant;
userVariant?: MessageBubbleVariant;
placeholder?: string;
className?: string;
viewportClassName?: string;
}
const DEFAULT_MESSAGES: ChatPreviewMessage[] = [
{
id: "welcome-question",
from: "user",
content: "What should we include in the first release?",
},
{
id: "welcome-answer",
from: "assistant",
content: "Start with the smallest workflow that still feels complete.",
},
];
const DEFAULT_REPLY =
"I’d include composing a prompt, following a streamed answer, recovering from an error, and returning to the latest response without losing the reader’s place.";
const CHARACTERS_PER_SECOND = 96;
const STREAM_DELAY = 140;
export function ChatPreview({
initialMessages = DEFAULT_MESSAGES,
reply = DEFAULT_REPLY,
showAvatars = false,
showMetadata = false,
showRail = false,
assistantVariant = "soft",
userVariant = "solid",
placeholder = "Send a message…",
className,
viewportClassName,
}: ChatPreviewProps) {
const reduce = useReducedMotion() ?? false;
const nextId = useRef(0);
const replyTimer = useRef<number | undefined>(undefined);
const [messages, setMessages] = useState<RenderedChatMessage[]>(() =>
initialMessages.map((message) => ({ ...message, animateIn: false })),
);
const [pending, setPending] = useState(false);
const [activeReply, setActiveReply] = useState<{
id: string;
content: string;
} | null>(null);
const loading = pending || activeReply !== null;
useEffect(() => {
if (!activeReply) return;
if (reduce) {
setMessages((current) =>
current.map((message) =>
message.id === activeReply.id
? { ...message, content: activeReply.content, streaming: false }
: message,
),
);
setActiveReply(null);
return;
}
const startedAt = performance.now() + STREAM_DELAY;
let frame = 0;
const stream = (now: number) => {
const cursor = Math.min(
activeReply.content.length,
Math.floor(
(Math.max(0, now - startedAt) / 1000) * CHARACTERS_PER_SECOND,
),
);
const content = activeReply.content.slice(0, cursor);
setMessages((current) =>
current.map((message) =>
message.id === activeReply.id && message.content !== content
? { ...message, content }
: message,
),
);
if (cursor < activeReply.content.length) {
frame = requestAnimationFrame(stream);
} else {
setMessages((current) =>
current.map((message) =>
message.id === activeReply.id
? { ...message, streaming: false }
: message,
),
);
setActiveReply(null);
}
};
frame = requestAnimationFrame(stream);
return () => cancelAnimationFrame(frame);
}, [activeReply, reduce]);
useEffect(
() => () => {
if (replyTimer.current) window.clearTimeout(replyTimer.current);
},
[],
);
const submit = useCallback(
(prompt: string) => {
if (loading) return;
const run = nextId.current++;
const userId = `sent-user-${run}`;
const assistantId = `sent-assistant-${run}`;
const response = typeof reply === "function" ? reply(prompt) : reply;
setMessages((current) => [
...current,
{ id: userId, from: "user", content: prompt, animateIn: true },
]);
setPending(true);
replyTimer.current = window.setTimeout(() => {
setMessages((current) => [
...current,
{
id: assistantId,
from: "assistant",
content: "",
animateIn: true,
streaming: true,
},
]);
setPending(false);
setActiveReply({ id: assistantId, content: response });
}, reduce ? 0 : 420);
},
[loading, reduce, reply],
);
const stop = useCallback(() => {
if (replyTimer.current) window.clearTimeout(replyTimer.current);
replyTimer.current = undefined;
setPending(false);
setMessages((current) =>
current.map((message) =>
message.streaming ? { ...message, streaming: false } : message,
),
);
setActiveReply(null);
}, []);
return (
<div
className={cn(
"flex h-[440px] w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-border/70 bg-background",
className,
)}
>
<MessageScroller
busy={loading}
navigation={showRail ? "rail" : undefined}
className="min-h-0 flex-1"
viewportClassName={cn("px-3 py-4", viewportClassName)}
contentClassName="min-h-full"
>
<MessageGroup spacing="default">
{messages.map((message) => (
<Message
key={message.id}
id={message.id}
from={message.from}
animateIn={message.from === "user" && message.animateIn}
>
{showAvatars ? (
<MessageAvatar>
{message.from === "user" ? <User /> : <Bot />}
</MessageAvatar>
) : null}
<MessageContent>
{showMetadata ? (
<MessageHeader>
<span className="font-medium text-foreground/70">
{message.from === "user" ? "You" : "Assistant"}
</span>
<span>{message.streaming ? "Responding" : "Now"}</span>
</MessageHeader>
) : null}
<MessageBubble
variant={
message.from === "user" ? userVariant : assistantVariant
}
animateIn={false}
>
<MessageBubbleContent>
{message.from === "assistant" ? (
<StreamingResponse
status={message.streaming ? "streaming" : "complete"}
showActions={false}
announce={false}
>
{message.content || <MessageTyping />}
</StreamingResponse>
) : (
message.content
)}
</MessageBubbleContent>
</MessageBubble>
{showMetadata && message.from === "user" ? (
<MessageFooter>Sent</MessageFooter>
) : null}
</MessageContent>
</Message>
))}
{pending ? (
<Message id="pending-assistant" from="assistant">
{showAvatars ? (
<MessageAvatar>
<Bot />
</MessageAvatar>
) : null}
<MessageContent>
<MessageTyping label="Preparing response" />
</MessageContent>
</Message>
) : null}
</MessageGroup>
</MessageScroller>
<div className="shrink-0 border-t border-border/60 p-2">
<PromptInput
minRows={1}
maxRows={1}
loading={loading}
onSubmit={submit}
onStop={stop}
placeholder={placeholder}
className="border-0 bg-muted shadow-none focus-within:border-transparent"
/>
</div>
</div>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import {
type ComponentPropsWithRef,
createContext,
type ReactNode,
useContext,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { MessageSideContext } from "@/components/agents/message-context";
export {
MessageBubble,
MessageBubbleCollapsible,
MessageBubbleContent,
MessageBubbleGroup,
} from "@/components/agents/message-bubble";
export { MessageScroller } from "@/components/agents/message-scroller";
export type { MessageScrollerProps } from "@/components/agents/message-scroller";
export type MessageFrom = "user" | "assistant";
interface MessageContextValue {
from: MessageFrom;
}
const MessageContext = createContext<MessageContextValue>({
from: "assistant",
});
export interface MessageProps
extends Omit<ComponentPropsWithRef<typeof motion.article>, "children"> {
from: MessageFrom;
/** Plays a trailing-edge pop-up once when this message row mounts. */
animateIn?: boolean;
children: ReactNode;
}
export interface MessageGroupProps extends ComponentPropsWithRef<"div"> {
spacing?: "compact" | "default";
}
export interface MessageAvatarProps extends ComponentPropsWithRef<"div"> {
/** Keep an empty avatar slot so grouped messages remain aligned. */
placeholder?: boolean;
}
export type MessageContentProps = ComponentPropsWithRef<"div">;
export type MessageHeaderProps = ComponentPropsWithRef<"div">;
export type MessageFooterProps = ComponentPropsWithRef<"div">;
export type MessageMarkerProps = ComponentPropsWithRef<"div">;
export interface MessageTypingProps extends ComponentPropsWithRef<"span"> {
label?: string;
}
// A sent row should rise from the live edge without changing measured layout.
const MESSAGE_POP_UP = {
type: "spring",
stiffness: 480,
damping: 32,
mass: 0.62,
} as const;
export function Message({
from,
animateIn = false,
children,
className,
initial,
animate,
transition,
exit,
style,
...props
}: MessageProps) {
const reduce = useReducedMotion() ?? false;
return (
<MessageSideContext.Provider value={from === "user" ? "end" : "start"}>
<MessageContext.Provider value={{ from }}>
<motion.article
data-slot="message"
data-from={from}
aria-label={props["aria-label"] ?? `${from} message`}
initial={
initial ??
(animateIn && !reduce
? {
opacity: 0,
transform: "translateY(8px) scale(0.95)",
}
: false)
}
animate={
animate ??
(animateIn && !reduce
? {
opacity: 1,
transform: "translateY(0px) scale(1)",
}
: { opacity: 1 })
}
exit={
exit ??
(reduce
? { opacity: 0 }
: {
opacity: 0,
transform: "translateY(-3px) scale(0.99)",
})
}
transition={
transition ?? (reduce ? { duration: 0.12 } : MESSAGE_POP_UP)
}
style={{
transformOrigin: from === "user" ? "100% 100%" : "0% 100%",
...style,
}}
className={cn(
"group/message flex w-full items-start gap-2",
from === "user" ? "flex-row-reverse" : "flex-row",
className,
)}
{...props}
>
{children}
</motion.article>
</MessageContext.Provider>
</MessageSideContext.Provider>
);
}
export function MessageGroup({
spacing = "compact",
className,
...props
}: MessageGroupProps) {
return (
<div
data-slot="message-group"
className={cn(
"flex w-full flex-col",
spacing === "compact" ? "gap-1.5" : "gap-4",
className,
)}
{...props}
/>
);
}
export function MessageAvatar({
placeholder = false,
children,
className,
...props
}: MessageAvatarProps) {
return (
<div
data-slot="message-avatar"
aria-hidden={placeholder || undefined}
className={cn(
"grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-xs font-medium text-muted-foreground [&_img]:size-full [&_img]:object-cover [&_svg]:size-3.5",
placeholder && "invisible",
className,
)}
{...props}
>
{children}
</div>
);
}
export function MessageContent({ className, ...props }: MessageContentProps) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-content"
className={cn(
"flex min-w-0 flex-1 flex-col gap-1.5",
from === "user" ? "items-end" : "items-start",
className,
)}
{...props}
/>
);
}
export function MessageHeader({ className, ...props }: MessageHeaderProps) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-header"
className={cn(
"flex items-center gap-1.5 px-1 text-[11px] leading-none text-muted-foreground",
from === "user" ? "justify-end" : "justify-start",
className,
)}
{...props}
/>
);
}
export function MessageFooter({ className, ...props }: MessageFooterProps) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-footer"
className={cn(
"flex min-h-5 items-center gap-1 px-1 text-[11px] text-muted-foreground",
from === "user" ? "justify-end" : "justify-start",
className,
)}
{...props}
/>
);
}
export function MessageMarker({ className, ...props }: MessageMarkerProps) {
return (
<div
data-slot="message-marker"
className={cn(
"mx-auto flex w-fit max-w-[88%] items-center gap-1.5 rounded-full bg-muted/70 px-2.5 py-1 text-center text-xs text-muted-foreground",
className,
)}
{...props}
/>
);
}
export function MessageTyping({
label = "Responding",
className,
...props
}: MessageTypingProps) {
const reduce = useReducedMotion() ?? false;
return (
<span
data-slot="message-typing"
className={cn("inline-flex h-5 items-center gap-1", className)}
{...props}
>
<span className="sr-only">{label}</span>
{[0, 1, 2].map((index) => (
<motion.span
key={index}
aria-hidden="true"
className="size-1 rounded-full bg-current"
animate={
reduce
? { opacity: 0.45 }
: { opacity: [0.28, 0.85, 0.28], y: [0, -2, 0] }
}
transition={{
duration: 1.05,
ease: EASE_OUT,
repeat: Number.POSITIVE_INFINITY,
delay: index * 0.14,
}}
/>
))}
</span>
);
}
"use client";
import { useReducedMotion } from "motion/react";
import {
type ComponentPropsWithRef,
type Ref,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
PreviewRail,
type PreviewRailItem,
} from "@/components/motion/preview-rail";
import { cn } from "@/lib/utils";
const PREVIEW_TITLE_LENGTH = 56;
const PREVIEW_DESCRIPTION_LENGTH = 88;
function truncateMessageText(text: string, limit: number) {
if (text.length <= limit) return text;
const excerpt = text.slice(0, limit);
const boundary = excerpt.lastIndexOf(" ");
return `${excerpt.slice(0, boundary > limit * 0.65 ? boundary : limit).trim()}…`;
}
function getMessageText(message: HTMLElement) {
const surface =
message.querySelector<HTMLElement>('[data-slot="message-bubble-content"]') ??
message.querySelector<HTMLElement>('[data-slot="message-content"]') ??
message;
return (surface.textContent ?? "").replace(/\s+/g, " ").trim();
}
function getMessagePreview(
message: HTMLElement,
assistantResponse?: HTMLElement,
) {
const text = getMessageText(message);
if (!text) {
return { label: "Message", description: undefined };
}
if (text.length <= PREVIEW_TITLE_LENGTH) {
const responseText = assistantResponse
? getMessageText(assistantResponse)
: "";
return {
label: text,
description: responseText
? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH)
: undefined,
};
}
const titleExcerpt = text.slice(0, PREVIEW_TITLE_LENGTH);
const titleBoundary = titleExcerpt.lastIndexOf(" ");
const titleEnd =
titleBoundary > PREVIEW_TITLE_LENGTH * 0.65
? titleBoundary
: PREVIEW_TITLE_LENGTH;
const label = `${text.slice(0, titleEnd).trim()}…`;
const responseText = assistantResponse
? getMessageText(assistantResponse)
: text.slice(titleEnd).trim();
return {
label,
description: responseText
? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH)
: undefined,
};
}
export interface MessageScrollerProps extends ComponentPropsWithRef<"div"> {
/** Keep streamed output pinned while the reader remains near the end. */
followOutput?: boolean;
/** Distance from the end that still counts as following the output. */
followThreshold?: number;
/** Smoothly follow growing content. */
smooth?: boolean;
/** Reports when the reader leaves or returns to the live edge. */
onFollowChange?: (following: boolean) => void;
/** Accessible label for the scrollable transcript. */
label?: string;
/** Marks the transcript as waiting for more streamed content. */
busy?: boolean;
/** Adds a compact rail for navigating between rendered Message rows. */
navigation?: "rail";
/** Accessible label for the optional message navigation rail. */
navigationLabel?: string;
viewportClassName?: string;
contentClassName?: string;
railClassName?: string;
viewportRef?: Ref<HTMLElement>;
viewportProps?: Omit<
ComponentPropsWithRef<"section">,
"children" | "className" | "ref"
>;
contentProps?: Omit<
ComponentPropsWithRef<"div">,
"children" | "className" | "ref"
>;
}
export function MessageScroller({
followOutput = true,
followThreshold = 56,
smooth = true,
onFollowChange,
label = "Conversation",
busy,
navigation,
navigationLabel = "Message navigation",
viewportClassName,
contentClassName,
railClassName,
viewportRef: externalViewportRef,
viewportProps,
contentProps,
className,
children,
...props
}: MessageScrollerProps) {
const reduce = useReducedMotion() ?? false;
const viewportRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const followingRef = useRef(followOutput);
const programmaticScrollRef = useRef(false);
const scrollTimerRef = useRef<number | undefined>(undefined);
const frameRef = useRef<number | undefined>(undefined);
const railFrameRef = useRef<number | undefined>(undefined);
const railIdRef = useRef(new WeakMap<HTMLElement, string>());
const railIdCounterRef = useRef(0);
const railTargetsRef = useRef(new Map<string, HTMLElement>());
const [railItems, setRailItems] = useState<PreviewRailItem[]>([]);
const [activeRailId, setActiveRailId] = useState("");
const [railOverflowing, setRailOverflowing] = useState(false);
const {
onScroll: onViewportScroll,
onWheel: onViewportWheel,
onTouchStart: onViewportTouchStart,
onKeyDown: onViewportKeyDown,
...restViewportProps
} = viewportProps ?? {};
const setViewportRef = useCallback(
(node: HTMLElement | null) => {
viewportRef.current = node;
if (typeof externalViewportRef === "function") {
externalViewportRef(node);
} else if (externalViewportRef) {
externalViewportRef.current = node;
}
},
[externalViewportRef],
);
const setFollowing = useCallback(
(next: boolean) => {
if (followingRef.current === next) return;
followingRef.current = next;
onFollowChange?.(next);
},
[onFollowChange],
);
const updateActiveRailItem = useCallback(() => {
if (navigation !== "rail") return;
const viewport = viewportRef.current;
const targets = [...railTargetsRef.current.entries()];
if (!viewport || targets.length === 0) return;
const viewportRect = viewport.getBoundingClientRect();
if (viewport.scrollTop <= followThreshold) {
const firstId = targets[0]?.[0] ?? "";
setActiveRailId((current) => (current === firstId ? current : firstId));
return;
}
const distanceFromEnd =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
if (distanceFromEnd <= followThreshold) {
const lastId = targets.at(-1)?.[0] ?? "";
setActiveRailId((current) => (current === lastId ? current : lastId));
return;
}
const viewportCenter = viewportRect.top + viewportRect.height / 2;
let nearestId = targets[0]?.[0] ?? "";
let nearestDistance = Number.POSITIVE_INFINITY;
for (const [id, element] of targets) {
const rect = element.getBoundingClientRect();
const messageCenter = rect.top + rect.height / 2;
const distance = Math.abs(messageCenter - viewportCenter);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestId = id;
}
}
setActiveRailId((current) =>
current === nearestId ? current : nearestId,
);
}, [followThreshold, navigation]);
const syncRailItems = useCallback(() => {
if (navigation !== "rail") return;
const content = contentRef.current;
const viewport = viewportRef.current;
if (!content || !viewport) return;
const messages = Array.from(
content.querySelectorAll<HTMLElement>('[data-slot="message"]'),
);
const targets = new Map<string, HTMLElement>();
const nextItems = messages.map((message, index) => {
let id = railIdRef.current.get(message);
if (!id) {
railIdCounterRef.current += 1;
id = `message-rail-${railIdCounterRef.current}`;
railIdRef.current.set(message, id);
}
targets.set(id, message);
const sender = message.dataset.from ?? "conversation";
const assistantResponse =
sender === "user"
? messages
.slice(index + 1)
.find((candidate) => candidate.dataset.from === "assistant")
: undefined;
const preview = getMessagePreview(message, assistantResponse);
return {
id,
label: preview.label,
description: preview.description,
ariaLabel: `Go to ${sender} message ${index + 1} of ${messages.length}`,
};
});
railTargetsRef.current = targets;
setRailItems((current) => {
const unchanged =
current.length === nextItems.length &&
current.every(
(item, index) =>
item.id === nextItems[index]?.id &&
item.label === nextItems[index]?.label &&
item.description === nextItems[index]?.description &&
item.ariaLabel === nextItems[index]?.ariaLabel,
);
return unchanged ? current : nextItems;
});
setRailOverflowing(
viewport.scrollHeight > viewport.clientHeight + 1 && messages.length > 1,
);
}, [navigation]);
const scheduleRailSync = useCallback(() => {
if (navigation !== "rail") return;
if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current);
railFrameRef.current = requestAnimationFrame(() => {
syncRailItems();
updateActiveRailItem();
});
}, [navigation, syncRailItems, updateActiveRailItem]);
const scrollToEnd = useCallback((behavior: ScrollBehavior) => {
const viewport = viewportRef.current;
if (!viewport) return;
programmaticScrollRef.current = true;
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
} else {
viewport.scrollTop = viewport.scrollHeight;
}
if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = window.setTimeout(() => {
programmaticScrollRef.current = false;
}, behavior === "smooth" ? 320 : 0);
}, []);
const handleScroll = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport || programmaticScrollRef.current) return;
const distance =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
setFollowing(distance <= followThreshold);
updateActiveRailItem();
}, [followThreshold, setFollowing, updateActiveRailItem]);
const leaveLiveEdge = useCallback(() => {
programmaticScrollRef.current = false;
}, []);
useLayoutEffect(() => {
followingRef.current = followOutput;
if (!followOutput) return;
frameRef.current = requestAnimationFrame(() => scrollToEnd("auto"));
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, [followOutput, scrollToEnd]);
useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
scheduleRailSync();
if (!followOutput || !followingRef.current) return;
scrollToEnd(reduce || !smooth ? "auto" : "smooth");
});
observer.observe(content);
return () => observer.disconnect();
}, [followOutput, reduce, scheduleRailSync, scrollToEnd, smooth]);
useEffect(() => {
if (navigation !== "rail") {
railTargetsRef.current.clear();
setRailItems([]);
setRailOverflowing(false);
return;
}
const content = contentRef.current;
const viewport = viewportRef.current;
if (!content || !viewport) return;
scheduleRailSync();
const mutationObserver =
typeof MutationObserver === "undefined"
? null
: new MutationObserver(scheduleRailSync);
mutationObserver?.observe(content, {
childList: true,
characterData: true,
subtree: true,
});
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleRailSync);
resizeObserver?.observe(content);
resizeObserver?.observe(viewport);
return () => {
mutationObserver?.disconnect();
resizeObserver?.disconnect();
};
}, [navigation, scheduleRailSync]);
useEffect(
() => () => {
if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);
if (frameRef.current) cancelAnimationFrame(frameRef.current);
if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current);
},
[],
);
const scrollToRailItem = useCallback(
(item: PreviewRailItem) => {
const viewport = viewportRef.current;
const target = railTargetsRef.current.get(item.id);
if (!viewport || !target) return;
const lastItem = railItems.at(-1)?.id === item.id;
setActiveRailId(item.id);
if (lastItem) {
setFollowing(true);
scrollToEnd(reduce || !smooth ? "auto" : "smooth");
return;
}
setFollowing(false);
programmaticScrollRef.current = true;
const viewportRect = viewport.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const top =
viewport.scrollTop +
targetRect.top -
viewportRect.top -
(viewport.clientHeight - targetRect.height) / 2;
const behavior = reduce || !smooth ? "auto" : "smooth";
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({ top, behavior });
} else {
viewport.scrollTop = top;
}
if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = window.setTimeout(() => {
programmaticScrollRef.current = false;
}, behavior === "smooth" ? 320 : 0);
},
[railItems, reduce, scrollToEnd, setFollowing, smooth],
);
const viewport = (
<section
ref={setViewportRef}
aria-label={label}
{...restViewportProps}
onScroll={(event) => {
handleScroll();
onViewportScroll?.(event);
}}
onWheel={(event) => {
leaveLiveEdge();
onViewportWheel?.(event);
}}
onTouchStart={(event) => {
leaveLiveEdge();
onViewportTouchStart?.(event);
}}
onKeyDown={(event) => {
if (["ArrowUp", "PageUp", "Home"].includes(event.key)) {
leaveLiveEdge();
}
onViewportKeyDown?.(event);
}}
className={cn(
"h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
navigation === "rail"
? "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: "[scrollbar-gutter:stable]",
viewportClassName,
navigation === "rail" && railOverflowing && "pr-10",
)}
>
<div
ref={contentRef}
role="log"
aria-live="polite"
aria-relevant="additions text"
aria-busy={busy}
className={contentClassName}
{...contentProps}
>
{children}
</div>
</section>
);
return (
<div
data-slot="message-scroller"
className={cn("min-h-0", className)}
{...props}
>
{navigation === "rail" ? (
<PreviewRail
items={railOverflowing ? railItems : []}
label={navigationLabel}
activeId={activeRailId}
onItemSelect={scrollToRailItem}
previewSide="before"
highlightActive
itemSize={14}
className="h-full min-h-0 overflow-hidden"
previewContainerClassName="right-8 left-3"
previewClassName="mr-1 w-64 max-w-full [&_[data-slot=preview-rail-card]]:h-20 [&_[data-slot=preview-rail-card]]:overflow-hidden [&_[data-slot=preview-rail-card]]:p-3 [&_[data-slot=preview-rail-title]]:line-clamp-1 [&_[data-slot=preview-rail-title]]:text-xs [&_[data-slot=preview-rail-title]]:leading-4 [&_[data-slot=preview-rail-description]]:line-clamp-2 [&_[data-slot=preview-rail-description]]:text-xs [&_[data-slot=preview-rail-description]]:leading-4"
railClassName={cn(
"absolute inset-y-3 right-1 w-7 content-center py-1 [&_[data-slot=preview-rail-item]]:w-7 [&_[data-slot=preview-rail-item]]:justify-end [&_[data-slot=preview-rail-tick]]:h-px [&_[data-slot=preview-rail-tick]]:w-4 [&_[data-slot=preview-rail-tick]]:origin-right",
railOverflowing
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0",
railClassName,
)}
>
{viewport}
</PreviewRail>
) : (
viewport
)}
</div>
);
}
"use client";
import { ArrowUp, Plus, Square } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
type TextareaHTMLAttributes,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Button } from "@/components/motion/button";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/motion/select";
import { SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface PromptModel {
value: string;
label: ReactNode;
icon?: ReactNode;
disabled?: boolean;
}
export interface PromptAction {
value: string;
label: ReactNode;
description?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
}
export interface PromptInputProps extends Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
"value" | "defaultValue" | "onChange" | "onSubmit" | "children"
> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
models?: PromptModel[];
model?: string;
defaultModel?: string;
onModelChange?: (model: string) => void;
actions?: PromptAction[];
onAction?: (action: string) => void;
onSubmit?: (value: string, model?: string) => void | Promise<void>;
loading?: boolean;
onStop?: () => void;
minRows?: number;
maxRows?: number;
leadingAction?: ReactNode;
className?: string;
}
export function PromptInput({
value,
defaultValue = "",
onValueChange,
models = [],
model,
defaultModel,
onModelChange,
actions = [],
onAction,
onSubmit,
loading = false,
onStop,
minRows = 2,
maxRows = 8,
leadingAction,
className,
disabled,
placeholder = "Ask the agent to do something…",
"aria-label": ariaLabel = "Prompt",
onKeyDown,
...textareaProps
}: PromptInputProps) {
const reduce = useReducedMotion() ?? false;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const measurementRef = useRef<HTMLDivElement>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
const [internalModel, setInternalModel] = useState(
defaultModel ?? models[0]?.value,
);
const [actionsOpen, setActionsOpen] = useState(false);
const currentValue = value ?? internalValue;
const currentModelValue = model ?? internalModel;
const currentModel = models.find(
(option) => option.value === currentModelValue,
);
const canSubmit = Boolean(currentValue.trim()) && !disabled && !loading;
const resizeTextarea = useCallback(() => {
const textarea = textareaRef.current;
const measurement = measurementRef.current;
if (!textarea || !measurement || textarea.value !== currentValue) return;
const lineHeight = 24;
const nextHeight = Math.min(
Math.max(measurement.scrollHeight, minRows * lineHeight),
maxRows * lineHeight,
);
const height = `${nextHeight}px`;
if (textarea.style.height !== height) textarea.style.height = height;
}, [currentValue, maxRows, minRows]);
useLayoutEffect(() => {
resizeTextarea();
}, [resizeTextarea]);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(resizeTextarea);
observer.observe(textarea);
return () => observer.disconnect();
}, [resizeTextarea]);
const setValue = (next: string) => {
if (value === undefined) setInternalValue(next);
onValueChange?.(next);
};
const setModel = (next: string) => {
if (model === undefined) setInternalModel(next);
onModelChange?.(next);
};
const submit = (event?: FormEvent) => {
event?.preventDefault();
const prompt = currentValue.trim();
if (!prompt || disabled || loading) return;
onSubmit?.(prompt, currentModelValue);
if (value === undefined) setInternalValue("");
textareaRef.current?.focus({ preventScroll: true });
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(event);
if (
event.defaultPrevented ||
event.key !== "Enter" ||
event.shiftKey ||
event.nativeEvent.isComposing
) {
return;
}
event.preventDefault();
submit();
};
return (
<form
onSubmit={submit}
className={cn(
"relative w-full rounded-2xl border border-border/80 bg-background p-2 transition-colors focus-within:border-foreground/25",
disabled && "opacity-60",
className,
)}
>
<div
ref={measurementRef}
aria-hidden="true"
className="pointer-events-none invisible absolute inset-x-2 top-0 whitespace-pre-wrap px-2 text-sm leading-6 [overflow-wrap:break-word]"
>
{`${currentValue}\u200b`}
</div>
<textarea
ref={textareaRef}
value={currentValue}
disabled={disabled}
placeholder={placeholder}
aria-label={ariaLabel}
rows={minRows}
{...textareaProps}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
className="scrollbar-hide block w-full resize-none overflow-y-auto bg-transparent px-2 pt-1.5 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/55"
/>
<div className="mt-1 flex min-h-8 items-center gap-1">
{actions.length ? (
<MorphPopover open={actionsOpen} onOpenChange={setActionsOpen}>
<MorphPopoverTrigger>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || loading}
aria-label="Add to prompt"
className="size-8 rounded-full"
>
<motion.span
aria-hidden="true"
animate={{ rotate: actionsOpen ? 45 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<Plus className="size-4" />
</motion.span>
</Button>
</MorphPopoverTrigger>
<MorphPopoverContent
side="top"
align="start"
sideOffset={8}
radius={12}
className="w-56 p-1.5"
>
{actions.map((action) => (
<button
key={action.value}
type="button"
disabled={action.disabled}
onClick={() => {
onAction?.(action.value);
setActionsOpen(false);
}}
className="flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left outline-none transition-colors hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{action.icon ? (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center text-muted-foreground [&_svg]:size-4">
{action.icon}
</span>
) : null}
<span className="min-w-0">
<span className="block text-sm text-foreground">
{action.label}
</span>
{action.description ? (
<span className="mt-0.5 block text-xs leading-4 text-muted-foreground">
{action.description}
</span>
) : null}
</span>
</button>
))}
</MorphPopoverContent>
</MorphPopover>
) : null}
{leadingAction}
{models.length ? (
<Select
value={currentModelValue}
onValueChange={setModel}
disabled={disabled || loading}
className="min-w-0"
>
<SelectTrigger className="h-8 w-auto max-w-52 rounded-xl border-0 bg-transparent px-2 py-0 text-xs hover:bg-muted focus-visible:ring-2">
<span className="flex min-w-0 items-center gap-1.5">
{currentModel?.icon ? (
<span className="grid size-4 shrink-0 place-items-center text-muted-foreground [&_svg]:size-3.5">
{currentModel.icon}
</span>
) : null}
<span className="truncate text-muted-foreground">
{currentModel?.label ?? "Choose model"}
</span>
</span>
</SelectTrigger>
<SelectContent className="right-auto w-52 shadow-none">
{models.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
className="py-2"
>
<span className="flex min-w-0 items-center gap-2">
{option.icon ? (
<span className="grid size-5 shrink-0 place-items-center text-muted-foreground [&_svg]:size-4">
{option.icon}
</span>
) : null}
<span className="min-w-0 truncate text-sm text-foreground">
{option.label}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Button
type={loading ? "button" : "submit"}
size="icon"
disabled={loading ? !onStop : !canSubmit}
aria-label={loading ? "Stop generating" : "Send prompt"}
onClick={loading ? onStop : undefined}
className="ml-auto size-8 rounded-full"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={loading ? "stop" : "send"}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 3, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.8 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="grid place-items-center"
>
{loading ? (
<Square className="size-3 fill-current" />
) : (
<ArrowUp className="size-4" />
)}
</motion.span>
</AnimatePresence>
</Button>
</div>
</form>
);
}
"use client";
import {
Check,
ChevronDown,
Copy,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
type CitationItem,
CitationList,
CitationStack,
} from "@/components/agents/citations";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type StreamingResponseStatus = "streaming" | "complete" | "error";
export type StreamingResponseFeedback = "up" | "down" | null;
export interface StreamingResponseProps {
/** Rendered response content. Pass plain text or the output of a Markdown renderer. */
children: ReactNode;
status?: StreamingResponseStatus;
/** Plain-text value copied by the built-in copy action. */
copyText?: string;
/** Overrides the built-in clipboard action. */
onCopy?: () => void | Promise<void>;
onRetry?: () => void;
/** Optional sources shown as a compact footer disclosure after streaming. */
sources?: CitationItem[];
sourcesOpen?: boolean;
defaultSourcesOpen?: boolean;
onSourcesOpenChange?: (open: boolean) => void;
sourceIdPrefix?: string;
feedback?: StreamingResponseFeedback;
defaultFeedback?: StreamingResponseFeedback;
onFeedbackChange?: (feedback: StreamingResponseFeedback) => void;
/** Set false when a surrounding conversation log announces streamed text. */
announce?: boolean;
/** Hides the built-in completion actions without changing response status. */
showActions?: boolean;
className?: string;
contentClassName?: string;
actionsClassName?: string;
}
function ResponseAction({
label,
active = false,
onClick,
children,
}: {
label: string;
active?: boolean;
onClick: () => void;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
return (
<motion.button
type="button"
aria-label={label}
title={label}
aria-pressed={label === "Helpful" || label === "Not helpful" ? active : undefined}
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className={cn(
"grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
active && "bg-muted text-foreground",
)}
>
{children}
</motion.button>
);
}
export function StreamingResponse({
children,
status = "streaming",
copyText,
onCopy,
onRetry,
sources = [],
sourcesOpen,
defaultSourcesOpen = false,
onSourcesOpenChange,
sourceIdPrefix,
feedback,
defaultFeedback = null,
onFeedbackChange,
announce = true,
showActions = true,
className,
contentClassName,
actionsClassName,
}: StreamingResponseProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const [copied, setCopied] = useState(false);
const [internalFeedback, setInternalFeedback] =
useState<StreamingResponseFeedback>(defaultFeedback);
const [internalSourcesOpen, setInternalSourcesOpen] =
useState(defaultSourcesOpen);
const copyTimer = useRef<number | undefined>(undefined);
const currentFeedback = feedback ?? internalFeedback;
const currentSourcesOpen = sourcesOpen ?? internalSourcesOpen;
const streaming = status === "streaming";
const complete = status === "complete";
const canCopy = Boolean(copyText || onCopy);
const hasSources = sources.length > 0;
const shouldShowActions =
showActions && !streaming && (canCopy || onRetry || complete || hasSources);
const sourcesContentId = `${baseId}-sources`;
const resolvedSourcePrefix =
sourceIdPrefix ?? `response-source-${baseId.replace(/:/g, "")}`;
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else if (copyText) await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [copyText, onCopy]);
const setFeedback = (next: Exclude<StreamingResponseFeedback, null>) => {
const value = currentFeedback === next ? null : next;
if (feedback === undefined) setInternalFeedback(value);
onFeedbackChange?.(value);
};
const setSourcesOpen = useCallback(
(next: boolean) => {
if (sourcesOpen === undefined) setInternalSourcesOpen(next);
onSourcesOpenChange?.(next);
},
[onSourcesOpenChange, sourcesOpen],
);
return (
<div
data-state={status}
aria-busy={streaming}
className={cn("w-full", className)}
>
<div
aria-live={announce ? "polite" : "off"}
className={cn(
"text-sm leading-6 text-foreground/90 [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5 [&_p+p]:mt-3 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted/45 [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5",
contentClassName,
)}
>
{children}
</div>
<AnimatePresence initial={false}>
{shouldShowActions ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}
className="mt-3"
>
<div className={cn("flex items-center gap-0.5", actionsClassName)}>
{canCopy ? (
<ResponseAction
label={copied ? "Copied" : "Copy response"}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</ResponseAction>
) : null}
{onRetry ? (
<ResponseAction label="Retry response" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ResponseAction>
) : null}
{complete ? (
<>
<ResponseAction
label="Helpful"
active={currentFeedback === "up"}
onClick={() => setFeedback("up")}
>
<ThumbsUp className="size-3.5" />
</ResponseAction>
<ResponseAction
label="Not helpful"
active={currentFeedback === "down"}
onClick={() => setFeedback("down")}
>
<ThumbsDown className="size-3.5" />
</ResponseAction>
</>
) : null}
{hasSources ? (
<button
type="button"
aria-expanded={currentSourcesOpen}
aria-controls={sourcesContentId}
onClick={() => setSourcesOpen(!currentSourcesOpen)}
className="group ml-1 inline-flex min-h-7 items-center gap-2 rounded-md px-1.5 text-xs text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<CitationStack citations={sources} />
<span className="tabular-nums">
{sources.length} {sources.length === 1 ? "source" : "sources"}
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentSourcesOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="text-muted-foreground/50 group-hover:text-muted-foreground"
>
<ChevronDown className="size-3" />
</motion.span>
</button>
) : null}
</div>
{hasSources ? (
<AgentDisclosure
id={sourcesContentId}
open={currentSourcesOpen}
>
<CitationList
citations={sources}
idPrefix={resolvedSourcePrefix}
className="mt-2 rounded-xl bg-muted p-2"
/>
</AgentDisclosure>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId, useState, type ReactNode } from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface PreviewRailItem {
id: string;
label: string;
ariaLabel?: string;
description?: ReactNode;
href?: string;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
}
export interface PreviewRailProps {
items: PreviewRailItem[];
label?: string;
orientation?: "vertical" | "horizontal";
activeId?: string;
defaultActiveId?: string;
onActiveChange?: (id: string) => void;
onItemSelect?: (item: PreviewRailItem) => void;
renderPreview?: (item: PreviewRailItem) => ReactNode;
showPreview?: boolean;
previewSide?: "before" | "after";
highlightActive?: boolean;
itemSize?: number;
children?: ReactNode;
className?: string;
railClassName?: string;
previewContainerClassName?: string;
previewClassName?: string;
}
function DefaultPreview({ item }: { item: PreviewRailItem }) {
return (
<div
data-slot="preview-rail-card"
className="rounded-2xl border border-border bg-card p-4 shadow-sm"
>
<p
data-slot="preview-rail-title"
className="font-medium text-card-foreground"
>
{item.label}
</p>
{item.description ? (
<div
data-slot="preview-rail-description"
className="mt-1 text-sm leading-6 text-muted-foreground"
>
{item.description}
</div>
) : null}
</div>
);
}
export function PreviewRail({
items,
label = "Section navigation",
orientation = "vertical",
activeId,
defaultActiveId,
onActiveChange,
onItemSelect,
renderPreview,
showPreview = true,
previewSide = "after",
highlightActive = false,
itemSize = 24,
children,
className,
railClassName,
previewContainerClassName,
previewClassName,
}: PreviewRailProps) {
const uid = useId();
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [internalActiveId, setInternalActiveId] = useState(
defaultActiveId ?? items[0]?.id ?? "",
);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const requestedActiveId = activeId ?? internalActiveId;
const selectedId = items.some((item) => item.id === requestedActiveId)
? requestedActiveId
: (items[0]?.id ?? "");
const displayedId = hoveredId ?? focusedId ?? "";
const highlightedId = displayedId || (highlightActive ? selectedId : "");
const displayedIndex = items.findIndex((item) => item.id === highlightedId);
const rowTemplate = items.length
? `repeat(${items.length}, ${itemSize}px)`
: undefined;
const isHorizontal = orientation === "horizontal";
const selectItem = (id: string) => {
if (activeId === undefined) setInternalActiveId(id);
onActiveChange?.(id);
};
return (
<motion.div
layoutRoot
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setFocusedId(null);
}
}}
className={cn(
"isolate relative flex w-full overflow-visible",
isHorizontal
? "min-h-64 flex-col items-center justify-center"
: "min-h-80",
className,
)}
>
<nav
aria-label={label}
onPointerLeave={() => setHoveredId(null)}
style={
isHorizontal
? { gridTemplateColumns: rowTemplate }
: { gridTemplateRows: rowTemplate }
}
className={cn(
"relative z-10 grid shrink-0",
isHorizontal
? "h-12 w-fit max-w-full self-center justify-center"
: "w-12 content-center",
railClassName,
)}
>
{items.map((item, index) => {
const selected = item.id === selectedId;
const highlighted = item.id === highlightedId;
const distance =
displayedIndex < 0 ? Number.POSITIVE_INFINITY : Math.abs(index - displayedIndex);
const scale = highlighted
? 1
: distance === 1
? 0.68
: distance === 2
? 0.44
: 0.25;
const itemContent = (
<>
<motion.span
data-slot="preview-rail-tick"
aria-hidden="true"
animate={isHorizontal ? { scaleY: scale } : { scaleX: scale }}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"block bg-current",
isHorizontal
? "h-12 w-0.5 origin-bottom"
: "h-0.5 w-12 origin-left",
highlighted ? "text-foreground" : undefined,
)}
/>
</>
);
const sharedClassName = cn(
"relative flex text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isHorizontal
? "h-12 w-6 items-end justify-center"
: "h-6 w-12 items-center",
);
const sharedStyle = isHorizontal
? { width: itemSize }
: { height: itemSize };
const handlePointerEnter = () => {
if (canHover) setHoveredId(item.id);
};
const handleFocus = (currentTarget: HTMLElement) => {
if (currentTarget.matches(":focus-visible")) {
setFocusedId(item.id);
}
};
const handleSelect = () => {
selectItem(item.id);
onItemSelect?.(item);
};
return item.href ? (
<a
key={item.id}
data-slot="preview-rail-item"
href={item.href}
target={item.target}
rel={
item.rel ??
(item.target === "_blank" ? "noreferrer noopener" : undefined)
}
aria-label={item.ariaLabel ?? item.label}
aria-current={selected ? "page" : undefined}
onPointerEnter={handlePointerEnter}
onMouseEnter={handlePointerEnter}
onPointerDown={() => setFocusedId(null)}
onFocus={(event) => handleFocus(event.currentTarget)}
onClick={handleSelect}
style={sharedStyle}
className={sharedClassName}
>
{itemContent}
</a>
) : (
<button
key={item.id}
data-slot="preview-rail-item"
type="button"
aria-label={item.ariaLabel ?? item.label}
aria-current={selected ? "location" : undefined}
onPointerEnter={handlePointerEnter}
onMouseEnter={handlePointerEnter}
onPointerDown={() => setFocusedId(null)}
onFocus={(event) => handleFocus(event.currentTarget)}
onClick={handleSelect}
style={sharedStyle}
className={sharedClassName}
>
{itemContent}
</button>
);
})}
</nav>
{showPreview ? (
<div
aria-hidden="true"
style={
isHorizontal
? { gridTemplateColumns: rowTemplate }
: { gridTemplateRows: rowTemplate }
}
className={cn(
"pointer-events-none absolute z-50 grid",
isHorizontal
? "top-1/2 left-1/2 h-5 w-fit max-w-full -translate-x-1/2 -translate-y-1/2 justify-center"
: previewSide === "before"
? "inset-y-0 right-16 left-4 content-center"
: "inset-y-0 right-4 left-16 content-center",
previewContainerClassName,
)}
>
{items.map((item) => (
<div
key={item.id}
style={
isHorizontal ? { width: itemSize } : { height: itemSize }
}
className={cn(
"relative flex items-center",
isHorizontal ? "justify-center" : undefined,
)}
>
{item.id === displayedId ? (
<div
className={cn(
isHorizontal
? "absolute bottom-12 left-1/2 w-72 -translate-x-1/2"
: cn(
"w-full max-w-sm",
previewSide === "before" && "ml-auto",
),
previewClassName,
)}
>
<motion.div
layoutId={`preview-rail-card-${uid}`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={item.id}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 4, filter: "blur(6px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: {
opacity: 0,
y: -2,
filter: "blur(4px)",
transition: {
duration: 0.12,
ease: EASE_OUT,
},
}
}
transition={{
duration: reduce ? 0 : 0.18,
ease: EASE_OUT,
}}
>
{renderPreview ? (
renderPreview(item)
) : (
<DefaultPreview item={item} />
)}
</motion.div>
</AnimatePresence>
</motion.div>
</div>
) : null}
</div>
))}
</div>
) : null}
{children ? (
<div className="min-h-0 min-w-0 flex-1">{children}</div>
) : null}
</motion.div>
);
}
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
triggerRef: React.MutableRefObject<HTMLElement | null>;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
rootRef.current &&
!rootRef.current.contains(target) &&
!contentRef.current?.contains(target)
)
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, setOpen]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef,
contentRef,
}),
[open, setOpen, toggle, baseId],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, (node: HTMLElement | null) => {
ctx.triggerRef.current = node;
}),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import { Check, ChevronDown } from "lucide-react";
import {
motion,
type Transition,
useReducedMotion,
type Variants,
} from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const INSTANT_TRANSITION: Transition = { duration: 0 };
// Spring with bounce powers the unfold/separation; per-property timings in the
// content choreograph it (see SelectContent). Mirrors bouncy-accordion's feel.
const CHEVRON_TRANSITION: Transition = { type: "spring", duration: 0.4, bounce: 0.3 };
const LIST_VARIANTS: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },
};
const ITEM_VARIANTS: Variants = {
hidden: { opacity: 0, y: -6, filter: "blur(3px)" },
show: { opacity: 1, y: 0, filter: "blur(0px)" },
};
type Placement = "bottom" | "top";
interface SelectContextValue {
value: string | undefined;
open: boolean;
setOpen: (open: boolean) => void;
select: (value: string) => void;
register: (value: string, label: string) => void;
unregister: (value: string) => void;
labelFor: (value: string | undefined) => string | undefined;
reduce: boolean;
triggerId: string;
listId: string;
disabled: boolean;
placement: Placement;
setPlacement: (p: Placement) => void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext(component: string) {
const ctx = useContext(SelectContext);
if (!ctx) throw new Error(`${component} must be used within <Select>`);
return ctx;
}
export interface SelectProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function Select({
value,
defaultValue,
onValueChange,
disabled = false,
className,
children,
}: SelectProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState(defaultValue);
const [labels, setLabels] = useState<Map<string, string>>(new Map());
const [placement, setPlacement] = useState<Placement>("bottom");
const controlled = value !== undefined;
const current = controlled ? value : internal;
const select = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
setOpen(false);
},
[controlled, onValueChange],
);
const register = useCallback((v: string, label: string) => {
setLabels((m) => (m.get(v) === label ? m : new Map(m).set(v, label)));
}, []);
const unregister = useCallback((v: string) => {
setLabels((m) => {
if (!m.has(v)) return m;
const next = new Map(m);
next.delete(v);
return next;
});
}, []);
// close on outside pointer / escape
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const ctx = useMemo<SelectContextValue>(
() => ({
value: current,
open,
setOpen,
select,
register,
unregister,
labelFor: (v) => (v === undefined ? undefined : labels.get(v)),
reduce,
triggerId: `${baseId}-trigger`,
listId: `${baseId}-list`,
disabled,
placement,
setPlacement,
}),
[
current,
open,
select,
register,
unregister,
labels,
reduce,
baseId,
disabled,
placement,
],
);
return (
<SelectContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative", className)}>
{children}
</div>
</SelectContext.Provider>
);
}
export interface SelectTriggerProps {
className?: string;
children: ReactNode;
}
export function SelectTrigger({ className, children }: SelectTriggerProps) {
const ctx = useSelectContext("SelectTrigger");
const isTop = ctx.placement === "top";
// edge facing the panel flattens then rounds; the far edge stays rounded.
// All four corners are specified so none gets stranded when placement flips.
const kf = ctx.open ? [0, 0, 12] : [12, 0, 12];
const kfT: Transition = ctx.reduce
? { duration: 0 }
: ctx.open
? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT }
: { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT };
return (
<motion.button
type="button"
id={ctx.triggerId}
disabled={ctx.disabled}
aria-haspopup="listbox"
aria-expanded={ctx.open}
aria-controls={ctx.listId}
onClick={() => ctx.setOpen(!ctx.open)}
// Gooey: the edge facing the panel snaps flat (panel attached) then rounds
// back once the panel pulls away — the two pinch apart.
initial={false}
animate={{
borderTopLeftRadius: isTop ? kf : 12,
borderTopRightRadius: isTop ? kf : 12,
borderBottomLeftRadius: isTop ? 12 : kf,
borderBottomRightRadius: isTop ? 12 : kf,
}}
transition={{
borderTopLeftRadius: isTop ? kfT : INSTANT_TRANSITION,
borderTopRightRadius: isTop ? kfT : INSTANT_TRANSITION,
borderBottomLeftRadius: isTop ? INSTANT_TRANSITION : kfT,
borderBottomRightRadius: isTop ? INSTANT_TRANSITION : kfT,
}}
className={cn(
"relative z-10 flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors",
"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
<motion.span
aria-hidden
animate={{ rotate: ctx.open ? 180 : 0 }}
transition={ctx.reduce ? { duration: 0 } : CHEVRON_TRANSITION}
className="text-muted-foreground"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</motion.button>
);
}
export interface SelectValueProps {
placeholder?: string;
className?: string;
}
export function SelectValue({ placeholder, className }: SelectValueProps) {
const ctx = useSelectContext("SelectValue");
const label = ctx.labelFor(ctx.value);
return (
<span
className={cn(label ? "text-foreground" : "text-muted-foreground", className)}
>
{label ?? placeholder ?? "Select"}
</span>
);
}
export interface SelectContentProps {
className?: string;
children: ReactNode;
}
export function SelectContent({ className, children }: SelectContentProps) {
const ctx = useSelectContext("SelectContent");
const innerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
const open = ctx.open;
const { setPlacement } = ctx;
useLayoutEffect(() => {
const node = innerRef.current;
if (!node) return;
const measure = () => setHeight(node.offsetHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
});
// On open, flip upward when there isn't room below and there's more above.
useLayoutEffect(() => {
if (!open) return;
const trigger = document.getElementById(ctx.triggerId);
const node = innerRef.current;
if (!trigger || !node) return;
const rect = trigger.getBoundingClientRect();
const h = node.offsetHeight;
const below = window.innerHeight - rect.bottom;
const above = rect.top;
setPlacement(below < h + 16 && above > below ? "top" : "bottom");
}, [open, ctx.triggerId, setPlacement]);
// Specify EVERY corner + both margins each render. The near edge (facing the
// trigger) animates flat->round and the gap opens on that side; the far edge
// stays rounded and its margin pinned to 0. Setting all of them avoids a
// stranded square corner when the placement flips between opens.
const isTop = ctx.placement === "top";
const nearGap = open ? 8 : 0;
const nearRadius = open ? 12 : 0;
const gapT: Transition = open
? { type: "spring", duration: 0.6, bounce: 0.5, delay: 0.12 }
: { type: "spring", duration: 0.3, bounce: 0.1 };
const radiusT: Transition = open
? { duration: 0.3, ease: EASE_OUT, delay: 0.14 }
: { duration: 0.16, ease: EASE_OUT };
// Items stay mounted (open just animates the panel) so each item's label
// registration persists — otherwise the trigger would fall back to the
// placeholder the moment the panel closes.
return (
<motion.div
id={ctx.listId}
role="listbox"
aria-labelledby={ctx.triggerId}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
ctx.reduce
? { opacity: open ? 1 : 0, height: open ? height : 0 }
: {
opacity: open ? 1 : 0,
height: open ? height : 0,
// gap opens on the side facing the trigger
marginTop: isTop ? 0 : nearGap,
marginBottom: isTop ? nearGap : 0,
// near corners go flat->round; far corners stay rounded
borderTopLeftRadius: isTop ? 12 : nearRadius,
borderTopRightRadius: isTop ? 12 : nearRadius,
borderBottomLeftRadius: isTop ? nearRadius : 12,
borderBottomRightRadius: isTop ? nearRadius : 12,
}
}
transition={
ctx.reduce
? { duration: 0.12 }
: {
opacity: open
? { duration: 0.18 }
: { duration: 0.16, delay: 0.12 },
height: open
? { type: "spring", duration: 0.42, bounce: 0.14 }
: { duration: 0.26, ease: EASE_OUT, delay: 0.14 },
marginTop: isTop ? INSTANT_TRANSITION : gapT,
marginBottom: isTop ? gapT : INSTANT_TRANSITION,
borderTopLeftRadius: isTop ? INSTANT_TRANSITION : radiusT,
borderTopRightRadius: isTop ? INSTANT_TRANSITION : radiusT,
borderBottomLeftRadius: isTop ? radiusT : INSTANT_TRANSITION,
borderBottomRightRadius: isTop ? radiusT : INSTANT_TRANSITION,
}
}
style={{
transformOrigin: isTop ? "bottom" : "top",
overflow: "hidden",
pointerEvents: open ? "auto" : "none",
}}
// flush against the trigger, then separates into its own rounded pill;
// sits above or below depending on available space
className={cn(
"absolute left-0 right-0 z-20 rounded-xl border border-border bg-background shadow-lg",
isTop ? "bottom-full" : "top-full",
className,
)}
>
<motion.div
ref={innerRef}
variants={ctx.reduce ? undefined : LIST_VARIANTS}
initial={false}
animate={open ? "show" : "hidden"}
className="p-1"
>
{children}
</motion.div>
</motion.div>
);
}
export interface SelectItemProps {
value: string;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function SelectItem({
value,
disabled = false,
className,
children,
}: SelectItemProps) {
const ctx = useSelectContext("SelectItem");
const selected = ctx.value === value;
const label = typeof children === "string" ? children : value;
useLayoutEffect(() => {
ctx.register(value, label);
return () => ctx.unregister(value);
}, [ctx.register, ctx.unregister, value, label]);
return (
<motion.li variants={ctx.reduce ? undefined : ITEM_VARIANTS}>
<button
type="button"
role="option"
aria-selected={selected}
disabled={disabled}
onClick={() => ctx.select(value)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
</motion.li>
);
}
"use client";
import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AgentDisclosureProps
extends Omit<HTMLMotionProps<"div">, "animate" | "initial"> {
open: boolean;
openHeight?: CSSProperties["height"];
}
/** Shared transform-only reveal for collapsible agent content. */
export function AgentDisclosure({
open,
openHeight = "auto",
className,
style,
transition,
...props
}: AgentDisclosureProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
{...props}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
reduce
? { opacity: open ? 1 : 0 }
: {
opacity: open ? 1 : 0,
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
y: open ? 0 : -4,
}
}
transition={
transition ?? {
duration: reduce ? 0 : open ? 0.22 : 0.14,
ease: EASE_OUT,
}
}
className={cn("overflow-hidden", className)}
style={{
...style,
height: open ? openHeight : 0,
pointerEvents: open ? undefined : "none",
transformOrigin: "top",
}}
/>
);
}
"use client";
import { BookOpenText, ChevronDown, ExternalLink, Globe2 } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useId,
useState,
} from "react";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { getFaviconUrl } from "@/lib/favicon";
import { cn } from "@/lib/utils";
export interface CitationItem {
id: string;
title: ReactNode;
domain?: ReactNode;
url?: string;
}
export interface CitationsProps {
citations: CitationItem[];
title?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
idPrefix?: string;
className?: string;
}
export interface CitationProps {
citationId: string;
index: number;
/** Must match the related Citations idPrefix. */
idPrefix: string;
className?: string;
}
export interface CitationListProps {
citations: CitationItem[];
idPrefix?: string;
className?: string;
}
export interface CitationStackProps {
citations: CitationItem[];
limit?: number;
className?: string;
}
function citationTargetId(prefix: string, citationId: string) {
return `${prefix}-${citationId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
export function Citation({
citationId,
index,
idPrefix,
className,
}: CitationProps) {
return (
<a
href={`#${citationTargetId(idPrefix, citationId)}`}
aria-label={`View citation ${index}`}
className={cn(
"mx-0.5 inline-flex min-w-4 -translate-y-0.5 items-center justify-center rounded-md bg-muted/60 px-1 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground no-underline outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{index}
</a>
);
}
export function CitationFavicon({
url,
className,
}: {
url?: string;
className?: string;
}) {
const favicon = url ? getFaviconUrl(url) : null;
const [failedUrl, setFailedUrl] = useState<string | null>(null);
return (
<span
aria-hidden="true"
className={cn(
"grid size-5 shrink-0 place-items-center text-muted-foreground",
className,
)}
>
{favicon && failedUrl !== favicon ? (
// biome-ignore lint/performance/noImgElement: Dynamic cross-site favicons keep this framework-agnostic registry component portable.
<img
src={favicon}
alt=""
width={16}
height={16}
referrerPolicy="no-referrer"
onError={() => setFailedUrl(favicon)}
className="size-4 rounded-sm object-contain"
/>
) : (
<Globe2 className="size-3.5" />
)}
</span>
);
}
export function CitationStack({
citations,
limit = 3,
className,
}: CitationStackProps) {
return (
<span
aria-hidden="true"
className={cn("flex -space-x-1.5", className)}
>
{citations.slice(0, limit).map((citation) => (
<CitationFavicon
key={citation.id}
url={citation.url}
className="size-6 rounded-full bg-background ring-2 ring-background"
/>
))}
</span>
);
}
function CitationRow({
citation,
index,
idPrefix,
}: {
citation: CitationItem;
index: number;
idPrefix: string;
}) {
const content = (
<>
<CitationFavicon url={citation.url} />
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="truncate text-sm font-medium text-foreground/80 transition-colors group-hover/citation:text-foreground">
{citation.title}
</span>
{citation.domain ? (
<span className="min-w-0 truncate text-xs text-muted-foreground/60">
{citation.domain}
</span>
) : null}
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className="grid size-5 place-items-center rounded-md bg-foreground/[0.05] text-[10px] font-semibold tabular-nums text-muted-foreground">
{index}
</span>
{citation.url ? (
<ExternalLink className="size-3.5 text-muted-foreground/40 transition-colors group-hover/citation:text-muted-foreground" />
) : null}
</span>
</>
);
const className =
"group/citation flex items-center gap-2 rounded-md px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring";
const id = citationTargetId(idPrefix, citation.id);
return citation.url ? (
<a
id={id}
href={citation.url}
target="_blank"
rel="noreferrer noopener"
className={className}
>
{content}
</a>
) : (
<div id={id} className={className}>
{content}
</div>
);
}
export function CitationList({
citations,
idPrefix,
className,
}: CitationListProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const resolvedPrefix =
idPrefix ?? `citation-list-${baseId.replace(/:/g, "")}`;
return (
<div className={cn("grid gap-0.5", className)}>
<AnimatePresence mode="popLayout">
{citations.map((citation, index) => (
<motion.div
layout="position"
key={citation.id}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.18, ease: EASE_OUT },
y: SPRING_LAYOUT,
layout: SPRING_LAYOUT,
}
}
>
<CitationRow
citation={citation}
index={index + 1}
idPrefix={resolvedPrefix}
/>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export function Citations({
citations,
title = "Sources",
open,
defaultOpen = false,
onOpenChange,
idPrefix,
className,
}: CitationsProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const contentId = `${baseId}-content`;
const resolvedPrefix =
idPrefix ?? `citation-${baseId.replace(/:/g, "")}`;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
return (
<div className={cn("w-full text-sm", className)}>
<button
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group -ml-1 flex min-h-8 items-center gap-2 rounded-lg px-1 text-left text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<BookOpenText className="size-4" />
<span className="font-medium">{title}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold tabular-nums">
{citations.length}
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="text-muted-foreground/60"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
open={currentOpen}
>
<CitationList
citations={citations}
idPrefix={resolvedPrefix}
className="mt-1"
/>
</AgentDisclosure>
</div>
);
}
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
Composition
Place the bubble inside message content so identity and layout remain separate from the visible response surface.
Message
├── MessageAvatar
└── MessageContent
└── MessageBubble
└── MessageBubbleContentNote: Message adds sender alignment, metadata, and avatar placement. Streaming Response provides the active and completed response lifecycle.
How it works
A bubble is the visible surface of one message, not the conversation itself. It should give content a clear reading shape without taking ownership of sender identity, delivery, or scroll behavior.
Updated