Display

Chat

A conversation between people. It follows new messages while you're at the bottom and leaves you where you are while you read back, with a button for what arrived meanwhile. Your messages show at once and say when they're delivered, read, or need a retry. This is the conversation itself; for a whole app with a conversation list, search, threads and details, use the Messaging block.

Installation

npx sinaui add chat

Examples

A conversation

Type and press Enter; Shift+Enter starts a new line. Point at a message, or tab to it, for its actions.

Thabit ibn Qurra

The Greek copy of the Conics arrived from Constantinople this morning.

Wednesday, September 30, 2026 at 3:48 PM
You

Finally! Is it the complete one?

Thursday, October 1, 2026 at 3:52 PM
Thabit ibn Qurra

Books one to seven. The eighth is still missing.

Thursday, October 1, 2026 at 3:53 PM
Thabit ibn Qurra

I’ll start on book five tonight.

Thursday, October 1, 2026 at 3:55 PM
You

Keep the Greek terms in the margin, we’ll settle the Arabic ones together.

Thursday, October 1, 2026 at 3:56 PM Read
Thabit ibn Qurra

Agreed. Can you check my reading of the second proposition tomorrow?

Thursday, October 1, 2026 at 3:58 PM
<script lang="ts">
	import Chat from '#lib/ui/Chat.svelte';
	import { conversation, me, users, wait } from './data';

	let messages = $state(
		conversation([
			['thabit', 'The Greek copy of the Conics arrived from Constantinople this morning.'],
			['hunayn', 'Finally! Is it the complete one?'],
			['thabit', 'Books one to seven. The eighth is still missing.'],
			['thabit', 'I’ll start on book five tonight.'],
			['hunayn', 'Keep the Greek terms in the margin, we’ll settle the Arabic ones together.'],
			['thabit', 'Agreed. Can you check my reading of the second proposition tomorrow?']
		])
	);
</script>

<div class="frame">
	<Chat bind:messages {users} {me} label="Chat with Thabit ibn Qurra" send={() => wait(600)} />
</div>

<style>
	.frame {
		inline-size: 100%;
		block-size: 28rem;
		border-radius: var(--ui-radius);
		background: var(--ui-surface);
		box-shadow: var(--ui-shadow-card);
		overflow: hidden;
	}
</style>

Delivered, read, and retry

Send something: it goes from Sending to Sent, Delivered and Read. Turn on “Fail the next send” to see Retry.

Maryam al-Asturlabi

The new astrolabe plate is ready for Baghdad’s latitude.

Wednesday, September 30, 2026 at 3:52 PM
You

Wonderful. Send the measurements when you can.

Thursday, October 1, 2026 at 3:55 PM
Maryam al-Asturlabi

Tonight, after I check them against the star table.

Thursday, October 1, 2026 at 3:56 PM
You

Also, could you engrave the Qibla line on the back?

Thursday, October 1, 2026 at 3:58 PM Delivered
<script lang="ts">
	import { onMount } from 'svelte';
	import Chat from '#lib/ui/Chat.svelte';
	import Switch from '#lib/ui/Switch.svelte';
	import type { ChatMessage } from '#lib/ui/chat.js';
	import { conversation, me, users, wait } from './data';

	let failNext = $state(false);
	let messages = $state(
		conversation([
			['maryam', 'The new astrolabe plate is ready for Baghdad’s latitude.'],
			['hunayn', 'Wonderful. Send the measurements when you can.', { status: 'read' }],
			['maryam', 'Tonight, after I check them against the star table.'],
			['hunayn', 'Also, could you engrave the Qibla line on the back?', { status: 'delivered' }]
		])
	);

	// Pending receipts, cancelled if the example goes away.
	const timers: ReturnType<typeof setTimeout>[] = [];
	onMount(() => () => timers.forEach(clearTimeout));
	const mark = (id: string, status: ChatMessage['status'], ms: number) =>
		timers.push(
			setTimeout(() => (messages = messages.map((x) => (x.id === id ? { ...x, status } : x))), ms)
		);

	// Sent, then delivered, then read, like a real service reporting back.
	async function send(m: ChatMessage) {
		await wait(700);
		if (failNext) {
			failNext = false;
			throw new Error('offline');
		}
		mark(m.id, 'delivered', 1200);
		mark(m.id, 'read', 3000);
	}
</script>

<div class="demo">
	<Switch bind:checked={failNext}>Fail the next send</Switch>
	<div class="frame">
		<Chat bind:messages {users} {me} label="Chat with Maryam al-Asturlabi" {send} />
	</div>
</div>

<style>
	.demo {
		display: grid;
		gap: 1rem;
		inline-size: 100%;
	}
	.frame {
		block-size: 26rem;
		border-radius: var(--ui-radius);
		background: var(--ui-surface);
		box-shadow: var(--ui-shadow-card);
		overflow: hidden;
	}
</style>

Replies and reactions

Press a quoted message to jump to the original. React from a message's actions; press a reaction to add or take back yours.

Al-Kindi

Draft of the letter on the five essences is on the copying desk.

Wednesday, September 30, 2026 at 3:48 PM
Thabit ibn Qurra

Read it last night. The part on time is the strongest.

Thursday, October 1, 2026 at 3:52 PM
Al-Kindi

Anyone have the Persian glossary? I need it for the cosmology terms.

Thursday, October 1, 2026 at 3:53 PM
You

@Al-Kindi it’s in the west reading room, second shelf.

Thursday, October 1, 2026 at 3:55 PM Read
Maryam al-Asturlabi

Can we meet after the noon prayer to go through the diagrams?

Thursday, October 1, 2026 at 3:56 PM
Thabit ibn Qurra

Works for me.

Thursday, October 1, 2026 at 3:58 PM
<script lang="ts">
	import Chat from '#lib/ui/Chat.svelte';
	import { conversation, me, users, wait } from './data';

	let messages = $state(
		conversation([
			['kindi', 'Draft of the letter on the five essences is on the copying desk.'],
			[
				'thabit',
				'Read it last night. The part on time is the strongest.',
				{ reactions: { '👍': ['kindi'] } }
			],
			['kindi', 'Anyone have the Persian glossary? I need it for the cosmology terms.'],
			[
				'hunayn',
				'@Al-Kindi it’s in the west reading room, second shelf.',
				{ replyTo: 'm2', reactions: { '🙏': ['kindi'], '❤️': ['thabit', 'maryam'] } }
			],
			['maryam', 'Can we meet after the noon prayer to go through the diagrams?'],
			['thabit', 'Works for me.', { replyTo: 'm4', reactions: { '👍': ['maryam', 'hunayn'] } }]
		])
	);
</script>

<div class="frame">
	<Chat
		bind:messages
		{users}
		{me}
		label="Translators’ group"
		send={() => wait(500)}
		save={() => wait(300)}
	/>
</div>

<style>
	.frame {
		inline-size: 100%;
		block-size: 30rem;
		border-radius: var(--ui-radius);
		background: var(--ui-surface);
		box-shadow: var(--ui-shadow-card);
		overflow: hidden;
	}
</style>

Pictures and files

Pictures open larger when pressed; files download. Attach your own with the paper clip.

Maryam al-Asturlabi

Here’s the tile pattern for the observatory floor.

star-tile.svg
Wednesday, September 30, 2026 at 3:52 PM
You

Beautiful. And the reading room?

Thursday, October 1, 2026 at 3:55 PM Read
Maryam al-Asturlabi Sent an attachment
reading-room.jpg
Thursday, October 1, 2026 at 3:56 PM
Maryam al-Asturlabi

And the measurements, as promised.

Thursday, October 1, 2026 at 3:58 PM
<script lang="ts">
	import starTile from './star-tile.svg';
	import Chat from '#lib/ui/Chat.svelte';
	import { conversation, me, users, wait } from './data';

	let messages = $state(
		conversation([
			[
				'maryam',
				'Here’s the tile pattern for the observatory floor.',
				{
					attachments: [
						{
							name: 'star-tile.svg',
							size: 4_812,
							type: 'image/svg+xml',
							url: starTile,
							width: 1,
							height: 1
						}
					]
				}
			],
			['hunayn', 'Beautiful. And the reading room?'],
			[
				'maryam',
				'',
				{
					attachments: [
						{
							name: 'reading-room.jpg',
							size: 218_430,
							type: 'image/jpeg',
							url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/A_Library_in_Golden_Islamic_Age.jpg/960px-A_Library_in_Golden_Islamic_Age.jpg',
							width: 960,
							height: 1280
						}
					]
				}
			],
			[
				'maryam',
				'And the measurements, as promised.',
				{
					attachments: [
						{
							name: 'baghdad-plate-measurements.txt',
							size: 96,
							type: 'text/plain',
							url: `data:text/plain,${encodeURIComponent('Baghdad plate\nLatitude 33° 20′\nAltitude circles every 2°\nQibla bearing 196°\n')}`
						}
					]
				}
			]
		])
	);
</script>

<div class="frame">
	<Chat bind:messages {users} {me} label="Plans from Maryam al-Asturlabi" send={() => wait(900)} />
</div>

<style>
	.frame {
		inline-size: 100%;
		block-size: 32rem;
		border-radius: var(--ui-radius);
		background: var(--ui-surface);
		box-shadow: var(--ui-shadow-card);
		overflow: hidden;
	}
</style>

Older messages

500 messages, loaded 40 at a time as you scroll up. What you're reading stays put while each page arrives above it.

Thabit ibn Qurra

Note 461 on the new paper.

Wednesday, September 30, 2026 at 12:01 AM
Maryam al-Asturlabi

Note 462 on the observatory.

Wednesday, September 30, 2026 at 1:01 AM
Al-Kindi

Note 463 on the Conics.

Wednesday, September 30, 2026 at 2:01 AM
You

Note 464 on the star table.

Wednesday, September 30, 2026 at 3:01 AM
Thabit ibn Qurra

Note 465 on the glossary.

Wednesday, September 30, 2026 at 4:01 AM
Maryam al-Asturlabi

Note 466 on Galen’s anatomy.

Wednesday, September 30, 2026 at 5:01 AM
Al-Kindi

Note 467 on the new paper.

Wednesday, September 30, 2026 at 6:01 AM
You

Note 468 on the observatory.

Wednesday, September 30, 2026 at 7:01 AM
Thabit ibn Qurra

Note 469 on the Conics.

Wednesday, September 30, 2026 at 8:01 AM
Maryam al-Asturlabi

Note 470 on the star table.

Wednesday, September 30, 2026 at 9:01 AM
Al-Kindi

Note 471 on the glossary.

Wednesday, September 30, 2026 at 10:01 AM
You

Note 472 on Galen’s anatomy.

Wednesday, September 30, 2026 at 11:01 AM
Thabit ibn Qurra

Note 473 on the new paper.

Wednesday, September 30, 2026 at 12:01 PM
Maryam al-Asturlabi

Note 474 on the observatory.

Wednesday, September 30, 2026 at 1:01 PM
Al-Kindi

Note 475 on the Conics.

Wednesday, September 30, 2026 at 2:01 PM
You

Note 476 on the star table.

Wednesday, September 30, 2026 at 3:01 PM
Thabit ibn Qurra

Note 477 on the glossary.

Wednesday, September 30, 2026 at 4:01 PM
Maryam al-Asturlabi

Note 478 on Galen’s anatomy.

Wednesday, September 30, 2026 at 5:01 PM
Al-Kindi

Note 479 on the new paper.

Wednesday, September 30, 2026 at 6:01 PM
You

Note 480 on the observatory.

Wednesday, September 30, 2026 at 7:01 PM
Thabit ibn Qurra

Note 481 on the Conics.

Wednesday, September 30, 2026 at 8:01 PM
Maryam al-Asturlabi

Note 482 on the star table.

Wednesday, September 30, 2026 at 9:01 PM
Al-Kindi

Note 483 on the glossary.

Wednesday, September 30, 2026 at 10:01 PM
You

Note 484 on Galen’s anatomy.

Wednesday, September 30, 2026 at 11:01 PM
Thabit ibn Qurra

Note 485 on the new paper.

Thursday, October 1, 2026 at 12:01 AM
Maryam al-Asturlabi

Note 486 on the observatory.

Thursday, October 1, 2026 at 1:01 AM
Al-Kindi

Note 487 on the Conics.

Thursday, October 1, 2026 at 2:01 AM
You

Note 488 on the star table.

Thursday, October 1, 2026 at 3:01 AM
Thabit ibn Qurra

Note 489 on the glossary.

Thursday, October 1, 2026 at 4:01 AM
Maryam al-Asturlabi

Note 490 on Galen’s anatomy.

Thursday, October 1, 2026 at 5:01 AM
Al-Kindi

Note 491 on the new paper.

Thursday, October 1, 2026 at 6:01 AM
You

Note 492 on the observatory.

Thursday, October 1, 2026 at 7:01 AM
Thabit ibn Qurra

Note 493 on the Conics.

Thursday, October 1, 2026 at 8:01 AM
Maryam al-Asturlabi

Note 494 on the star table.

Thursday, October 1, 2026 at 9:01 AM
Al-Kindi

Note 495 on the glossary.

Thursday, October 1, 2026 at 10:01 AM
You

Note 496 on Galen’s anatomy.

Thursday, October 1, 2026 at 11:01 AM
Thabit ibn Qurra

Note 497 on the new paper.

Thursday, October 1, 2026 at 12:01 PM
Maryam al-Asturlabi

Note 498 on the observatory.

Thursday, October 1, 2026 at 1:01 PM
Al-Kindi

Note 499 on the Conics.

Thursday, October 1, 2026 at 2:01 PM
You

Note 500 on the star table.

Thursday, October 1, 2026 at 3:01 PM Read
<script lang="ts">
	import Chat from '#lib/ui/Chat.svelte';
	import type { ChatMessage } from '#lib/ui/chat.js';
	import { me, users, wait } from './data';

	// 500 messages, one an hour going back; 40 load at a time as you scroll up.
	const authors = ['thabit', 'hunayn', 'kindi', 'maryam'];
	const topics = [
		'the Conics',
		'the star table',
		'the glossary',
		'Galen’s anatomy',
		'the new paper',
		'the observatory'
	];
	const all: ChatMessage[] = Array.from({ length: 500 }, (_, i) => ({
		id: `h${i}`,
		author: authors[(i * 7) % 4],
		text: `Note ${i + 1} on ${topics[i % topics.length]}.`,
		at: Date.now() - (500 - i) * 3_600_000,
		status: authors[(i * 7) % 4] === me ? 'read' : undefined
	}));
	let messages = $state(all.slice(-40));

	async function older() {
		await wait(700);
		const first = all.findIndex((m) => m.id === messages[0]?.id);
		return all.slice(Math.max(0, first - 40), first);
	}
</script>

<div class="frame">
	<Chat bind:messages {users} {me} label="Library log" {older} send={() => wait(500)} />
</div>

<style>
	.frame {
		inline-size: 100%;
		block-size: 30rem;
		border-radius: var(--ui-radius);
		background: var(--ui-surface);
		box-shadow: var(--ui-shadow-card);
		overflow: hidden;
	}
</style>

Typing and new messages

Send something and Al-Kindi types back. Scroll up first, then press “Thabit writes in”: you stay where you are and a button counts what's new.

Al-Kindi

I’m in the reading room until sunset if anyone needs the glossary.

Thursday, October 1, 2026 at 3:56 PM
You

Thanks! Ask me anything about the Galen translation.

Thursday, October 1, 2026 at 3:58 PM Read
<script lang="ts">
	import { onMount } from 'svelte';
	import Button from '#lib/ui/Button.svelte';
	import Chat from '#lib/ui/Chat.svelte';
	import { conversation, me, users, wait } from './data';

	let messages = $state(
		conversation([
			['kindi', 'I’m in the reading room until sunset if anyone needs the glossary.'],
			['hunayn', 'Thanks! Ask me anything about the Galen translation.']
		])
	);
	let typing = $state<string[]>([]);
	let unread = $state<string>();
	// Pending demo replies, cancelled if the example goes away.
	const timers: ReturnType<typeof setTimeout>[] = [];
	const later = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms));
	onMount(() => () => timers.forEach(clearTimeout));

	const replies = [
		'Good question. Let me check the Syriac copy first.',
		'In the margin of folio 12 there’s a note about exactly that.',
		'I agree. We should ask Thabit, he read it twice.',
		'Send me the passage and I’ll compare it tonight.'
	];
	let turn = 0;

	// Someone else writes: typing first, then the message.
	function incoming(author: string, text: string) {
		typing = [...typing, author];
		later(() => {
			typing = typing.filter((t) => t !== author);
			messages = [...messages, { id: crypto.randomUUID(), author, text, at: Date.now() }];
		}, 1600);
	}

	async function send() {
		await wait(500);
		later(() => incoming('kindi', replies[turn++ % replies.length]), 600);
	}
</script>

<div class="demo">
	<div class="frame">
		<Chat bind:messages {users} {me} {typing} {unread} label="Chat with Al-Kindi" {send} />
	</div>
	<Button
		variant="secondary"
		onclick={() =>
			incoming('thabit', 'Did anyone see the new paper from Samarkand? It’s much smoother.')}
	>
		Thabit writes in
	</Button>
</div>

<style>
	.demo {
		display: grid;
		justify-items: start;
		gap: 1rem;
		inline-size: 100%;
	}
	.frame {
		inline-size: 100%;
		block-size: 26rem;
		border-radius: var(--ui-radius);
		background: var(--ui-surface);
		box-shadow: var(--ui-shadow-card);
		overflow: hidden;
	}
</style>

Props

PropTypeDefault
messagesChatMessage[], oldest first (bindable)[]
usersChatUser[], you included (required)
mestring: your user id (required)
variant'bubbles' (yours on the far side) or 'flat' (everyone on one side)'bubbles'
send(message, files) => Promise: resolve to mark it sent, throw to offer Retry
save(message) => Promise: saves an edit, delete or reaction; throw to undo it
older() => Promise<ChatMessage[]>: the page before; empty at the start
unreadstring: the first unread message's id, for the New divider
typingstring[]: who's typing[]
thread(message) => void: adds Reply in thread and a replies link
menu{ label, icon, run, show? }[]: more items for each message's menu[]
hide(message) => Promise: adds Delete for me
onread(id) => void: the newest message you've seen
draftstring: keeps unsent text per conversation
label, placeholderstring
attachmentsbooleantrue
headerSnippet: above the messages

Keyboard

KeyDoes
EnterSends; while @ suggestions are open, picks one
Shift EnterA new line
↑ in an empty boxEdits your last message
↑ ↓ on a messageMoves to the message before or after
Home End on a messageThe first or last loaded message
Tab on a messageInto its actions: react, reply, more
EscapeCancels an edit or a reply; on a message, back to the box

Accessibility

  • Messages from others are announced as they arrive; yours and older pages aren't.
  • Each message is read as who sent it and what it says, with the full time as its description.
  • Only one message is in the tab order; the arrows move between them.
  • Delivery, read and failed states are written out, not only shown as ticks.
  • Links open in a new tab and message text is never treated as HTML.
  • With reduced motion, messages appear without sliding and scrolling jumps instead of gliding.
Introduction
Theming
Changelog
Button
Action button
Confirm button
Copy button
Hold to confirm
Split button
Calendar
Checkbox
Color picker
Combobox
Date picker
Date range picker
File drop
Form
Input
Mention input
Multi select
Number field
OTP input
Password input
Phone input
Radio cards
Radio group
Rich text editor
Search field
Segmented control
Select
Signature pad
Slider
Switch
Tag input
Upload
Wheel picker
Textarea
Time field
Toggle group
Toolbar
Alert dialog
Command palette
Context menu
Dialog
Drawer
Dropdown
Hover card
Popover
Tooltip
Announcement bar
Alert
Empty state
Meter
Progress
Rolling number
Skeleton
Spinner
Toast
Avatar
Avatar group
Badge
Card
Carousel
Chart
Chat
Code block
Data table
Folder
Kbd
Mind map
Resizable panels
Scroll area
Separator
Sortable
Table
Tag group
Virtual list
Audio player
Image
Image compare
Video
Visualizer
Breadcrumb
File tree
Menubar
Navigation menu
Pagination
Sidebar layout
Stepper
Tabs
Alarm clock
Clock
Dynamic island
Fluid orb
Hourglass
Stopwatch
Timer
AI chat
AI media
Auth
Board
Booking
Dashboard
File explorer
Messaging
Notifications
Settings
Team
Overview
Pricing
Agent chat
Calendar
Voice agent
Mail inbox
CMS
Blog
Marketing site
E-commerce
AI agents
SaaS landing page
SaaS app
Accordion
Dark theme
Light theme