おはよう。今日の予定は?
MessageScroller
Chat viewport
The scrolling transcript of a chat: it opens at the latest message, follows new ones while you're at the bottom, keeps your place while you read back, and loads history at the top.
Streamed, growing replies are followed too. Pair it with Message and Bubble for the rows and Marker for day separators and typing indicators.
Basic
Today
脚本の続き。第3話まで。
Tomoya
夜までには送る。
了解。急がなくていいよ。
vue
<script setup lang="ts">
import { ref, useTemplateRef } from "vue";
import {
BlessBubble,
BlessButton,
BlessInput,
BlessInputGroup,
BlessMarker,
BlessMessage,
BlessMessageScroller,
} from "blessing-ui";
type Msg = { id: number; who: "megumi" | "me"; text: string };
let id = 0;
const line = (who: Msg["who"], text: string): Msg => ({ id: ++id, who, text });
const messages = ref<Msg[]>([
line("megumi", "おはよう。今日の予定は?"),
line("me", "脚本の続き。第3話まで。"),
line("me", "夜までには送る。"),
line("megumi", "了解。急がなくていいよ。"),
]);
const draft = ref("");
const streaming = ref(false);
const scroller = useTemplateRef<InstanceType<typeof BlessMessageScroller>>("scroller");
async function send() {
if (!draft.value.trim()) return;
messages.value.push(line("me", draft.value));
draft.value = "";
streaming.value = true;
const m = line("megumi", "");
messages.value.push(m);
for (const ch of "そうなんだ。じゃあ、私は買い出しに行ってくるね。") {
await new Promise((r) => setTimeout(r, 40));
m.text += ch;
}
streaming.value = false;
}
function loadHistory() {
scroller.value?.loadHistory(() => {
messages.value.unshift(
...Array.from({ length: 8 }, (_, i) =>
line(i % 2 ? "me" : "megumi", `(過去ログ ${i + 1})`),
),
);
});
}
</script>
<template>
<div class="col" style="max-width: 560px">
<BlessMessageScroller
ref="scroller"
height="320px"
style="border: 1px solid var(--bless-color-border)"
@reach-top="loadHistory"
>
<BlessMarker variant="separator">Today</BlessMarker>
<BlessMessage
v-for="(m, i) in messages"
:key="m.id"
:name="m.who === 'me' ? 'Tomoya' : 'Megumi'"
:align="m.who === 'me' ? 'end' : 'start'"
:compact="messages[i - 1]?.who === m.who"
>
<BlessBubble
:align="m.who === 'me' ? 'end' : 'start'"
:variant="m.who === 'me' ? 'outline' : 'surface'"
>{{ m.text }}</BlessBubble
>
</BlessMessage>
<BlessMarker v-if="streaming" shimmer>Megumi is typing…</BlessMarker>
</BlessMessageScroller>
<form @submit.prevent="send">
<BlessInputGroup>
<BlessInput v-model="draft" placeholder="message" aria-label="Message" />
<template #suffix><BlessButton size="sm" type="submit">Send</BlessButton></template>
</BlessInputGroup>
</form>
<small
>Scroll to top → loads 8 older lines without jumping. Scroll up, send → “↓ Latest”
appears.</small
>
</div>
</template>- It is a named log (
label), so new messages are announced politely. While a reply is streaming in, setaria-busyon that message so it's read once when it's done, not word by word. - The jump button appears when you've scrolled up;
@reach-topasks for history andloadHistory(insert)keeps your place while it's added. Scrolling doesn't animate under reduced motion.
Usage
ts
import { BlessMessageScroller } from "blessing-ui";API
Props
| Name | Type | Default | Description |
|---|---|---|---|
threshold | number | 48 | px from bottom still counted as "at bottom" |
topThreshold | number | 80 | px from top that fires `reach-top` (history load) |
initial | "top" | "bottom" | "bottom" | where to open: latest message or top of transcript |
jumpLabel | string | "Latest" | |
height | string | "400px" | |
label | string | "Messages" | names the transcript |
Events
| Name | Payload | Description |
|---|---|---|
reach-top | [] | |
at-bottom | [value: boolean] |
Slots
| Name | Scope | Description |
|---|---|---|
default | — | |
jump | — |
Exposed
| Name | Type | Description |
|---|---|---|
scrollToBottom | (behavior?: ScrollBehavior) => void | |
scrollTo | (id: string, behavior?: ScrollBehavior) => void | |
loadHistory | (insert: () => void | Promise<void>) => Promise<void> | |
atBottom | boolean |