AI Chat Fancy Stable
StreamingText
Renders a growing string as a live token stream: the appended delta lands tinted and settles, with an optional block cursor while the response is in flight
Preview
svelte
<script lang="ts">
import { onMount } from "svelte";
import { StreamingText } from "fancy-ui-svelte";
const source =
"A model answers one token at a time, so the surface it lands on should show that. Each chunk arrives tinted and settles into the paragraph a moment later, which makes the seam between what was already there and what just came in legible without moving any of the text around.";
const words = source.split(" ");
let text = $state("");
let streaming = $state(false);
onMount(() => {
// A looping demo that keeps re-animating is exactly what reduced motion is
// asking us not to do, so it gets the finished answer instead.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
text = source;
return;
}
let timer: ReturnType<typeof setTimeout>;
let i = 0;
function tick() {
if (i < words.length) {
// The whole text so far, never the delta — that is the contract.
text = i === 0 ? words[0] : `${text} ${words[i]}`;
i += 1;
timer = setTimeout(tick, 90);
return;
}
streaming = false;
timer = setTimeout(restart, 2400);
}
function restart() {
text = "";
i = 0;
streaming = true;
timer = setTimeout(tick, 160);
}
restart();
return () => clearTimeout(timer);
});
</script>
<div class="bg-card w-full max-w-xl rounded-lg border p-5">
<p class="text-muted-foreground mb-3 text-xs font-medium tracking-wide uppercase">Assistant</p>
<StreamingText {text} {streaming} class="text-sm leading-relaxed" tintColor="#6366f1" />
</div>Installation
pnpm add fancy-ui-svelte
import { StreamingText } from 'fancy-ui-svelte'
Usage
svelte
<script lang="ts">
import { StreamingText } from 'fancy-ui-svelte';
</script>
<StreamingText />Examples
Basic Usage
svelte
<script lang="ts">
import { onMount } from "svelte";
import { StreamingText } from "$lib/fancy-ui/streaming-text";
const source =
"A model answers one token at a time, so the surface it lands on should show that. Each chunk arrives tinted and settles into the paragraph a moment later, which makes the seam between what was already there and what just came in legible without moving any of the text around.";
const words = source.split(" ");
let text = $state("");
let streaming = $state(false);
onMount(() => {
// A looping demo that keeps re-animating is exactly what reduced motion is
// asking us not to do, so it gets the finished answer instead.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
text = source;
return;
}
let timer: ReturnType<typeof setTimeout>;
let i = 0;
function tick() {
if (i < words.length) {
// The whole text so far, never the delta — that is the contract.
text = i === 0 ? words[0] : `${text} ${words[i]}`;
i += 1;
timer = setTimeout(tick, 90);
return;
}
streaming = false;
timer = setTimeout(restart, 2400);
}
function restart() {
text = "";
i = 0;
streaming = true;
timer = setTimeout(tick, 160);
}
restart();
return () => clearTimeout(timer);
});
</script>
<div class="bg-card w-full max-w-xl rounded-lg border p-5">
<p class="text-muted-foreground mb-3 text-xs font-medium tracking-wide uppercase">Assistant</p>
<StreamingText {text} {streaming} class="text-sm leading-relaxed" tintColor="#6366f1" />
</div>Markdown Streaming
svelte
<script lang="ts">
import { onMount } from "svelte";
import { StreamingText } from "$lib/fancy-ui/streaming-text";
const source = `**Markdown mode** re-parses the whole document on every chunk, so structure appears the moment it becomes valid:
- a bullet resolves as soon as its line is complete
- inline \`code\` and **emphasis** snap in mid-sentence
\`\`\`ts
const res = await fetch("/api/chat");
\`\`\`
There is no delta tint here — only plain mode can wrap new text in a span of its own.`;
const words = source.split(" ");
let text = $state("");
let streaming = $state(false);
onMount(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
text = source;
return;
}
let timer: ReturnType<typeof setTimeout>;
let i = 0;
function tick() {
if (i < words.length) {
text = i === 0 ? words[0] : `${text} ${words[i]}`;
i += 1;
timer = setTimeout(tick, 90);
return;
}
streaming = false;
timer = setTimeout(restart, 2400);
}
function restart() {
text = "";
i = 0;
streaming = true;
timer = setTimeout(tick, 160);
}
restart();
return () => clearTimeout(timer);
});
</script>
<div class="bg-card w-full max-w-xl rounded-lg border p-5">
<p class="text-muted-foreground mb-3 text-xs font-medium tracking-wide uppercase">Assistant</p>
<StreamingText {text} {streaming} markdown class="text-sm" />
</div>Props
| Prop | Type | Default | Description |
|---|---|---|---|
text * | string | - | The accumulated text so far, not the latest delta; reassign as chunks arrive |
streaming | boolean | false | Show a soft block cursor after the last character |
markdown | boolean | false | Render as markdown instead of the tinted plain-text stream; disables the delta tint |
settleMs | number | 350 | How long a newly arrived chunk stays tinted in ms, plain mode only |
tintColor | string | - | Colour a chunk fades from, and the cursor's fill; sets --ft-tint-color |
onComplete | () => void | - | Called once when streaming goes from true to false |
Links
Related components
Ch
ChatError
Quiet inline failure banner for a chat turn: the error, an optional detail line, and a retry button that disables itself while the retry is in flight
AI Chat Stable
Ch
ChatMessage
One conversation turn, aligned and dressed by its role, streaming its body while the answer arrives, with an action rail that fades in on hover and a version navigator underneath
AI Chat Stable
Ch
ChatPanel
The shell a conversation lives in: a sticky header, a transcript that pins itself to the bottom while an answer arrives and offers the way back once you scroll up, and a sticky composer row
AI Chat Stable