1import { DialogueBox, DialogueMessage } from "@/components/ui/pxl/dialogue-box";2
3export default function Preview() {4 return (5 <DialogueBox className="size-full w-(--widget-width-sm)">6 <DialogueMessage7 className="h-[2lh]"8 text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed at urna in sapien eleifend pellentesque. Integer porta molestie mi vitae faucibus. Donec elementum consequat nibh id mollis. Phasellus in sagittis elit, et blandit augue. Aliquam scelerisque, leo vitae cursus porta, libero tortor facilisis arcu, vitae interdum nulla lacus vitae ipsum. Maecenas euismod, nibh sed tincidunt sodales, sem lectus commodo magna, non tristique orci sapien nec elit. Aliquam a faucibus nisl. Proin nunc quam, auctor eget nibh nec, accumsan finibus nisi. Morbi non orci vel turpis venenatis mollis."9 onEnd={() => console.log("onEnd")}10 />11 </DialogueBox>12 );13}Installation
Section titled “Installation”pnpm dlx shadcn@latest add pxl-ui/registry/dialogue-box
Install the following dependencies:
pnpm add streamdown
Copy and paste the following code into your project.
1import {2 type ComponentProps,3 type CSSProperties,4 memo,5 type PropsWithChildren,6 useEffect,7 useRef,8 useState,9} from "react";10import { Streamdown } from "streamdown";11
12import {13 Avatar,14 AvatarFallback,15 AvatarImage,16} from "@/components/ui/pxl/avatar";17import { Card, CardContent } from "@/components/ui/pxl/card";18import { cn } from "@/lib/utils";19
20const BOTTOM_THRESHOLD = 4;21
22const DialogueInner = memo(23 ({24 className,25 isAnimating,26 ...props27 }: { isAnimating?: boolean } & ComponentProps<typeof Streamdown>) => (28 <Streamdown29 className={cn(30 "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",31 isAnimating &&32 "[&>*:last-child]:after:content-['▊'] [&>*:last-child]:after:ml-0.5 [&>*:last-child]:after:animate-blink [&>*:last-child]:after:text-current",33 className,34 )}35 {...props}36 />37 ),38 (prevProps, nextProps) =>39 prevProps.children === nextProps.children &&40 nextProps.isAnimating === prevProps.isAnimating,41);42
43function DialogueMessage({44 characterDelay = 60,45 className,46 displayCursor = false,47 text,48 onEnd,49}: {50 characterDelay?: number;51 displayCursor?: boolean;52 className?: string;53 text: string;54 onEnd?: () => void;55}) {56 const [current, setCurrent] = useState("");57 const [isAnimating, setIsAnimating] = useState(false);58 const versionRef = useRef(0);59 const scrollRef = useRef<HTMLDivElement>(null);60 const stickToBottomRef = useRef(true);61
62 function handleScroll() {63 const el = scrollRef.current;64 if (!el) return;65 const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;66 stickToBottomRef.current = distanceFromBottom <= BOTTOM_THRESHOLD;67 }68
69 // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on current change70 useEffect(() => {71 const el = scrollRef.current;72 if (!el || !stickToBottomRef.current) return;73 el.scrollTop = el.scrollHeight;74 }, [current]);75
76 useEffect(() => {77 if (!text) return;78 const version = ++versionRef.current;79
80 function sleep(ms: number) {81 return new Promise<void>((resolve, reject) => {82 const id = setTimeout(() => {83 if (versionRef.current !== version) reject("cancelled");84 else resolve();85 }, ms);86 return () => clearTimeout(id);87 });88 }89
90 function commit(text: string) {91 setCurrent((cur) => cur + text);92 }93
94 async function run() {95 setCurrent("");96 setIsAnimating(true);97 stickToBottomRef.current = true;98 const words = text.split(" ");99
100 for (const word of words) {101 for (let i = 0; i < word.length; i++) {102 commit(word[i]);103 await sleep(characterDelay);104 }105 commit(" ");106 await sleep(characterDelay);107 }108
109 onEnd?.();110 }111
112 run()113 .catch(() => {})114 .finally(() => {115 if (versionRef.current === version) setIsAnimating(false);116 });117 }, [characterDelay, onEnd, text]);118
119 return (120 <div121 ref={scrollRef}122 onScroll={handleScroll}123 className={cn(124 "overflow-auto scrollbar-none whitespace-pre-wrap break-all",125 className,126 )}127 style={128 {129 "--tw-duration": `${characterDelay}ms`,130 } as CSSProperties131 }132 >133 <DialogueInner134 isAnimating={displayCursor && isAnimating}135 className="size-full"136 >137 {current}138 </DialogueInner>139 </div>140 );141}142
143function DialoguePortrait({144 alt,145 className,146 fallback,147 src,148}: {149 alt?: string;150 className?: string;151 fallback?: string;152 src?: string;153}) {154 return (155 <Avatar className={className} size="xl">156 <AvatarImage alt={alt} src={src} />157 <AvatarFallback>{fallback}</AvatarFallback>158 </Avatar>159 );160}161
162function DialogueBox({163 children,164 ...props165}: ComponentProps<typeof Card> & PropsWithChildren) {166 return (167 <Card {...props}>168 <CardContent className="flex justify-between gap-2 [--card-spacing:--spacing(1)]">{children}</CardContent>169 </Card>170 );171}172
173export { DialogueBox, DialogueMessage, DialoguePortrait };Update the import paths to match your project setup.
import { DialogueBox, DialogueMessage, DialoguePortrait} from "@/components/ui/pxl/dialogue-box"<DialogueBox className="size-full"> <DialogueMessage className="h-[2lh]" text={text} /> <DialoguePortrait className="bg-primary" src={portrait} /></DialogueBox>Composition
Section titled “Composition”Use the following composition to build a DialogueBox:
DialogueBox├── DialogueMessage└── DialoguePortraitWith portrait
Section titled “With portrait”IHS
1import { DialogueBox, DialogueMessage, DialoguePortrait } from "@/components/ui/pxl/dialogue-box";2
3export default function WithPortrait() {4 return (5 <DialogueBox className="size-full w-(--widget-width-md)">6 <DialogueMessage7 className="h-[2lh]"8 text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed at urna in sapien eleifend pellentesque. Integer porta molestie mi vitae faucibus. Donec elementum consequat nibh id mollis. Phasellus in sagittis elit, et blandit augue. Aliquam scelerisque, leo vitae cursus porta, libero tortor facilisis arcu, vitae interdum nulla lacus vitae ipsum. Maecenas euismod, nibh sed tincidunt sodales, sem lectus commodo magna, non tristique orci sapien nec elit. Aliquam a faucibus nisl. Proin nunc quam, auctor eget nibh nec, accumsan finibus nisi. Morbi non orci vel turpis venenatis mollis."9 onEnd={() => console.log("onEnd")}10 />11 <DialoguePortrait12 className="bg-primary"13 alt="@hero"14 fallback="IHS"15 src="https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/portraits/hero.png"16 />17 </DialogueBox>18 );19}