Message Scroller
A reader-aware conversation viewport that follows streamed output at the live edge and releases control when the reader moves away.
Preview
"use client";
import {
Message,
MessageContent,
MessageGroup,
} from "@/components/agents/message";
import {
MessageBubble,
MessageBubbleContent,
} from "@/components/agents/message-bubble";
import { MessageScroller } from "@/components/agents/message-scroller";
const messages = [
{
id: "release-question",
from: "user" as const,
content: "What should the first release include?",
},
{
id: "release-answer",
from: "assistant" as const,
content: "Start with the smallest workflow that still feels complete.",
},
{
id: "states-question",
from: "user" as const,
content: "Include streaming and recovery states too.",
},
{
id: "states-answer",
from: "assistant" as const,
content: "Yes. Those states make the first version feel dependable.",
},
];
export function MessageScrollerUsage() {
return (
<MessageScroller
navigation="rail"
className="h-[420px]"
viewportClassName="px-4 py-5"
contentClassName="min-h-full"
>
<MessageGroup spacing="default">
{messages.map((message) => (
<Message key={message.id} id={message.id} from={message.from}>
<MessageContent>
<MessageBubble
variant={message.from === "user" ? "solid" : "soft"}
>
<MessageBubbleContent>{message.content}</MessageBubbleContent>
</MessageBubble>
</MessageContent>
</Message>
))}
</MessageGroup>
</MessageScroller>
);
}
"use client";
// beui.dev/components/agents/message-scroller
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>
);
}
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
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
"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-scroller
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 { 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>
);
}
"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 { 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 { 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 { createContext } from "react";
export type MessageSide = "start" | "end";
export const MessageSideContext = createContext<MessageSide | undefined>(
undefined,
);
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>
);
}
API Reference
followOutput?booleanKeep streamed output pinned while the reader remains near the end.
truefollowThreshold?numberDistance from the end that still counts as following the output.
56smooth?booleanSmoothly follow growing content.
trueonFollowChange?((following: boolean) => void)Reports when the reader leaves or returns to the live edge.
—label?stringAccessible label for the scrollable transcript.
Conversationbusy?booleanMarks the transcript as waiting for more streamed content.
—navigation?"rail"Adds a compact rail for navigating between rendered Message rows.
—navigationLabel?stringAccessible label for the optional message navigation rail.
Message navigationviewportClassName?string—contentClassName?string—railClassName?string—viewportRef?Ref<HTMLElement>—viewportProps?Omit<DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>, "className" | "children" | "ref">—contentProps?Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "className" | "children" | "ref">—className?string—Composition
Keep the scrolling viewport outside the message primitives it follows.
MessageScroller
└── MessageGroup
└── Message
└── MessageContentNote: Message provides stable, semantic rows for the transcript. Prompt Input starts new turns without owning transcript movement. Streaming Response supplies the growing content the viewport follows.
How it works
A streaming transcript is not ordinary overflow. It must follow new output while the reader stays at the live edge, then stop moving the moment they choose to inspect earlier work.
Updated