Introduction
BubbleList is built on the Bubble component and is used to display a list of chat bubbles. It has built-in virtual scrolling (virtua/vue), auto-follow-to-bottom, scroll state machine, unread count, bidirectional pagination loading, back-to-bottom button, and mixed node rendering — all out of the box, configure as needed.
Code Examples
Basic Usage
Quickly render a group of chat bubbles via the list array. Each object in the array is passed through to the built-in Bubble component — all Bubble properties (content, placement, loading, shape, variant, etc.) can be configured directly. Adding, removing, or modifying messages only requires maintaining this array.
💡 Tip
Control the list height via the max-height property or the parent container's height — a scrollbar appears automatically when content overflows. See Bubble docs for detailed item properties.
<script setup lang="ts">
import type {
BubbleListItemProps,
BubbleListProps
} from 'vue-element-plus-x/types/BubbleList';
type listType = BubbleListItemProps & {
key: number;
role: 'user' | 'ai';
isMarkdown?: boolean;
typing?: boolean;
isFog?: boolean;
};
const list: BubbleListProps<listType>['list'] = generateFakeItems(5);
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
const content =
role === 'ai'
? '💖 Thank you for using Element Plus X ! Your support is our strongest motivation for open source ~'.repeat(
5
)
: `Hahaha, let me try`;
const loading = false;
const shape = 'corner';
const variant = role === 'ai' ? 'filled' : 'outlined';
const isMarkdown = false;
const typing = role === 'ai' ? i === count - 1 : false;
const avatar =
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4';
messages.push({
key,
role,
placement,
content,
loading,
shape,
variant,
isMarkdown,
typing,
isFog: role === 'ai',
avatar,
avatarSize: '24px',
avatarGap: '12px'
});
}
return messages;
}
</script>
<template>
<div class="story-stage">
<BubbleList :list="list" />
</div>
</template>
<style scoped lang="scss">
.story-stage {
height: 450px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
Scroll Control Methods
autoScroll is on (default), appending messages auto-scrolls to bottom and unread count stays 0. To experience unread badge + back-to-bottom button, first turn off autoScroll below, then manually scroll up and append messages — the view stays still, unread +1, and the back-to-bottom button appears. This is the core V2 state machine experience.
Precisely control scroll position via three component instance methods: scrollToTop(), scrollToBottom(), scrollToBubble(index) — all support a smooth parameter for smooth scrolling.
Also exposes currentScrollState and currentUnreadCount instance properties. You can also listen to @scroll-state-change / @unread-count-change events for real-time state awareness (AT_BOTTOM / SCROLLED_UP / HAS_NEW_MESSAGES).
<script setup lang="ts">
import type {
BubbleListInstance,
BubbleListScrollState
} from 'vue-element-plus-x/types/BubbleList';
interface listType {
key: number;
role: 'user' | 'ai';
placement: 'start' | 'end';
content: string;
loading: boolean;
shape: string;
variant: string;
avatar: string;
avatarSize: string;
}
const bubbleItems = ref<listType[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null);
const targetIndex = ref(0);
const scrollState = ref<BubbleListScrollState>('AT_BOTTOM');
const unreadCount = ref(0);
const autoScrollEnabled = ref(true);
let nextKey = 0;
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
const content =
role === 'ai'
? '💖 Thank you for using Element Plus X ! Your support is our strongest motivation for open source ~'
: `Hahaha, let me try`;
messages.push({
key,
role,
placement,
content,
loading: false,
shape: 'corner',
variant: role === 'ai' ? 'filled' : 'outlined',
avatar:
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4',
avatarSize: '32px'
});
}
return messages;
}
function createMessage(role: 'user' | 'ai', content: string): listType {
nextKey += 1;
const isUser = role === 'user';
return {
key: nextKey,
role,
placement: isUser ? 'end' : 'start',
content,
loading: false,
shape: 'corner',
variant: isUser ? 'outlined' : 'filled',
avatar: isUser
? 'https://avatars.githubusercontent.com/u/76239030?v=4'
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
avatarSize: '32px'
};
}
function resetConversation() {
bubbleItems.value = generateFakeItems(10);
nextKey = bubbleItems.value.length;
targetIndex.value = Math.max(bubbleItems.value.length - 1, 0);
scrollState.value = 'AT_BOTTOM';
unreadCount.value = 0;
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
}
function addMessage() {
const i = bubbleItems.value.length;
const isUser = !!(i % 2);
const content = isUser
? 'Hahaha, let me try'
: '💖 Thank you for using Element Plus X ! Your support is our strongest motivation for open source ~'.repeat(
2
);
const msg = createMessage(isUser ? 'user' : 'ai', content);
bubbleItems.value.push(msg);
targetIndex.value = bubbleItems.value.length - 1;
}
function addUserMessage() {
const msg = createMessage(
'user',
`User follow-up question ${nextKey + 1}: please continue expanding the interaction scenario.`
);
bubbleItems.value.push(msg);
targetIndex.value = bubbleItems.value.length - 1;
}
function addAiMessage() {
const msg = createMessage(
'ai',
`AI latest reply ${nextKey + 1}: verify that when autoScroll is on, messages auto-scroll to the bottom. When off, unread count accumulates and the back-to-bottom button appears.`.repeat(
(nextKey % 2) + 1
)
);
bubbleItems.value.push(msg);
targetIndex.value = bubbleItems.value.length - 1;
}
function addBurstMessages() {
bubbleItems.value.push(
createMessage(
'user',
'Burst append: first insert a short message, observe whether the list anchor stays stable.'
)
);
bubbleItems.value.push(
createMessage(
'ai',
'Second message intentionally longer, to verify scrolling and dynamic height behavior when appending multiple messages at once.'.repeat(
2
)
)
);
bubbleItems.value.push(
createMessage(
'ai',
'Third message kept short for easy observation of scroll position sticking to bottom.'
)
);
targetIndex.value = bubbleItems.value.length - 1;
}
function clearMessage() {
bubbleItems.value = [];
nextKey = 0;
}
function scrollToTop() {
bubbleListRef.value?.scrollToTop();
}
function scrollBottom() {
bubbleListRef.value?.scrollToBottom();
}
function scrollToBubble() {
const index = Math.min(
targetIndex.value,
Math.max(bubbleItems.value.length - 1, 0)
);
bubbleListRef.value?.scrollToBubble(index);
}
function handleScrollStateChange(state: BubbleListScrollState) {
scrollState.value = state;
}
function handleUnreadCountChange(count: number) {
unreadCount.value = count;
}
onMounted(() => {
resetConversation();
});
</script>
<template>
<div class="component-container">
<div class="tip-banner">
<span class="tip-icon">i</span>
<span>
<strong>V2 auto-scroll rules</strong>: When <code>autoScroll</code> is
on (default), appending messages auto-scrolls to bottom and unread count
stays 0.
<br />
To experience <strong>unread badge + back-to-bottom button</strong>,
first <strong>turn off autoScroll</strong> below, then manually scroll
up and append messages — the view stays still, unread +1, and the
back-to-bottom button appears. This is the core V2 state machine
experience.
</span>
</div>
<div class="top-wrap">
<div class="btn-list" style="align-items: center">
<span style="font-size: 13px; color: #606266"> autoScroll: </span>
<el-switch
v-model="autoScrollEnabled"
active-text="On (auto-scroll to bottom)"
inactive-text="Off (accumulate unread)"
/>
</div>
<div class="btn-list">
<el-button type="primary" plain @click="addMessage">
Add Message
</el-button>
<el-button type="primary" plain @click="addUserMessage">
Append User Message
</el-button>
<el-button type="primary" plain @click="addAiMessage">
Append AI Message
</el-button>
<el-button type="warning" plain @click="addBurstMessages">
Burst Append 3 Messages
</el-button>
<el-button type="danger" plain @click="clearMessage">
Clear Message List
</el-button>
<el-button type="primary" plain @click="scrollToTop">
Scroll to Top
</el-button>
<el-button type="success" plain @click="scrollBottom">
Scroll to Bottom
</el-button>
<el-input-number
v-model="targetIndex"
:min="0"
:max="Math.max(bubbleItems.length - 1, 0)"
controls-position="right"
size="default"
/>
<el-button type="primary" plain @click="scrollToBubble">
Scroll to Bubble {{ targetIndex }}
</el-button>
<el-button type="info" plain @click="resetConversation">
Reset Session
</el-button>
</div>
</div>
<div class="status-row">
<div class="status-chip">
<span>Scroll State</span>
<strong :class="`state-${scrollState.toLowerCase()}`">{{
scrollState
}}</strong>
</div>
<div class="status-chip">
<span>Unread Count</span>
<strong>{{ unreadCount }}</strong>
</div>
<div class="status-chip">
<span>Total Messages</span>
<strong>{{ bubbleItems.length }}</strong>
</div>
<div class="status-chip">
<span>Target Index</span>
<strong>{{ targetIndex }}</strong>
</div>
</div>
<div class="story-stage">
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
:auto-scroll="autoScrollEnabled"
@scroll-state-change="handleScrollStateChange"
@unread-count-change="handleUnreadCountChange"
/>
</div>
</div>
</template>
<style scoped lang="less">
.component-container {
padding: 12px;
.tip-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: linear-gradient(135deg, #ecf5ff 0%, #f0f9eb 100%);
border: 1px solid #d9ecff;
margin-bottom: 16px;
font-size: 13px;
color: #409eff;
.tip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: #409eff;
color: #fff;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
}
.btn-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.top-wrap {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #fff;
border: 1px solid #e4e7ed;
span {
font-size: 12px;
color: #909399;
}
strong {
font-size: 14px;
color: #303133;
}
.state-at_bottom {
color: #67c23a;
}
.state-scrolled_up {
color: #e6a23c;
}
.state-has_new_messages {
color: #f56c6c;
}
}
.story-stage {
height: 450px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
Auto Scroll Control
All new messages (user / AI) will automatically scroll to the bottom, unread count stays 0. Try scrolling up and then appending a message to see if it still auto-scrolls to bottom.
autoScroll (default true) controls whether new messages automatically scroll to the bottom. When disabled, new messages accumulate unread count instead — this is a prerequisite for showing unread badges.
Special Behavior for Streaming Output
Even when autoScroll is enabled, streaming increments only follow when the user is at the bottom; scrolling up won't force a jump back, avoiding reading interruption.
<script setup lang="ts">
import type {
BubbleListInstance,
BubbleListScrollState
} from 'vue-element-plus-x/types/BubbleList';
interface MessageItem {
key: number;
role: 'user' | 'ai';
content: string;
placement: 'start' | 'end';
loading?: boolean;
}
const bubbleListRef = ref<BubbleListInstance | null>(null);
const autoScroll = ref(true);
const scrollState = ref<BubbleListScrollState>('AT_BOTTOM');
const unreadCount = ref(0);
let nextKey = 100;
const bubbleItems = ref<MessageItem[]>(buildSeedList());
function buildSeedList(): MessageItem[] {
const list: MessageItem[] = [];
for (let i = 0; i < 8; i++) {
const isUser = i % 2 !== 0;
list.push({
key: i + 1,
role: isUser ? 'user' : 'ai',
content: isUser
? `User message ${i + 1}: Hi, I'd like to learn about BubbleList's auto-scroll feature.`
: `AI reply ${i + 1}: BubbleList's autoScroll prop controls whether new messages automatically scroll to the bottom. Enabled by default.`,
placement: isUser ? 'end' : 'start'
});
}
nextKey = 9;
return list;
}
function resetConversation() {
bubbleItems.value = buildSeedList();
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
}
function appendMessage(role: 'user' | 'ai') {
bubbleItems.value.push({
key: ++nextKey,
role,
content:
role === 'user'
? 'User appended message: This is a test message to observe the effect of the autoScroll toggle on auto-scrolling.'
: 'AI appended reply: When autoScroll is enabled, this message will automatically appear in the visible area at the bottom.',
placement: role === 'user' ? 'end' : 'start'
});
}
function handleScrollStateChange(state: BubbleListScrollState) {
scrollState.value = state;
}
function handleUnreadCountChange(count: number) {
unreadCount.value = count;
}
</script>
<template>
<div class="autoscroll-demo">
<!-- Control Panel -->
<div class="control-panel">
<div class="panel-header">
<span class="header-icon">⚙️</span>
<span>AutoScroll Control Panel</span>
</div>
<!-- Core: autoScroll Toggle -->
<div class="control-row core-switch">
<span class="switch-label">
<strong>autoScroll</strong>
<span class="switch-desc">{{
autoScroll
? 'Enabled — New messages auto-scroll to bottom'
: 'Disabled — Accumulate unread, manual scroll needed'
}}</span>
</span>
<el-switch v-model="autoScroll" active-text="ON" inactive-text="OFF" />
</div>
<el-divider style="margin: 8px 0" />
<!-- Action Buttons -->
<div class="action-buttons">
<el-button
type="primary"
plain
size="small"
@click="appendMessage('user')"
>
+ User Message
</el-button>
<el-button
type="success"
plain
size="small"
@click="appendMessage('ai')"
>
+ AI Reply
</el-button>
<el-button size="small" @click="resetConversation"> Reset </el-button>
</div>
<!-- Behavior Hint -->
<div class="behavior-hint" :class="autoScroll ? 'hint-on' : 'hint-off'">
<div class="hint-icon">
{{ autoScroll ? '✅' : '⚠️' }}
</div>
<div class="hint-text">
<template v-if="autoScroll">
<strong>Current mode: Auto-follow</strong><br />
All new messages (user / AI) will automatically scroll to the
bottom, unread count stays 0. Try scrolling up and then appending a
message to see if it still auto-scrolls to bottom.
</template>
<template v-else>
<strong>Current mode: Manual control</strong><br />
New messages no longer trigger automatic scrolling, unread count
starts accumulating. The back-to-bottom button will show a red
number badge; clicking it smoothly scrolls to bottom and resets
unread to zero.
</template>
</div>
</div>
</div>
<!-- BubbleList -->
<div class="story-stage">
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
:auto-scroll="autoScroll"
:always-show-scrollbar="true"
:show-back-button="true"
@scroll-state-change="handleScrollStateChange"
@unread-count-change="handleUnreadCountChange"
>
<!-- Custom back-to-bottom button (with unread badge) -->
<template #backToBottom="context">
<div
class="custom-back-btn"
:class="{ 'has-unread': context.unreadCount > 0 }"
@click="context.scrollToBottom(true)"
>
<span class="btn-icon">↓</span>
<span class="btn-text">Back to bottom</span>
<span v-if="context.unreadCount > 0" class="btn-badge">
{{ context.unreadCount > 99 ? '99+' : context.unreadCount }}
</span>
</div>
</template>
</BubbleList>
</div>
<!-- Status Bar -->
<div class="status-bar">
<div class="status-item">
<span class="status-label">scrollState</span>
<el-tag
:type="
scrollState === 'AT_BOTTOM'
? 'success'
: scrollState === 'SCROLLED_UP'
? 'warning'
: 'danger'
"
size="small"
>
{{ scrollState }}
</el-tag>
</div>
<div class="status-item">
<span class="status-label">unreadCount</span>
<el-tag :type="unreadCount > 0 ? 'danger' : 'info'" size="small">
{{ unreadCount }}
</el-tag>
</div>
<div class="status-item">
<span class="status-label">autoScroll</span>
<el-tag :type="autoScroll ? 'success' : 'danger'" size="small">
{{ autoScroll ? 'ON' : 'OFF' }}
</el-tag>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.autoscroll-demo {
display: flex;
flex-direction: column;
gap: 12px;
.story-stage {
height: 450px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
}
}
.control-panel {
border: 1px solid #e4e7ed;
border-radius: 12px;
padding: 16px;
.panel-header {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
.header-icon {
font-size: 18px;
}
}
.core-switch {
background: #fafafa;
border-radius: 8px;
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
.switch-label {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #303133;
font-size: 14px;
}
.switch-desc {
font-size: 12px;
color: #909399;
}
}
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.behavior-hint {
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
display: flex;
gap: 8px;
&.hint-on {
background: #f0f9eb;
border: 1px solid #e1f3d8;
color: #67c23a;
}
&.hint-off {
background: #fef0f0;
border: 1px solid #fde2e2;
color: #f56c6c;
}
.hint-icon {
font-size: 18px;
flex-shrink: 0;
margin-top: 1px;
}
strong {
font-weight: 600;
}
}
}
.status-bar {
display: flex;
gap: 16px;
padding: 10px 14px;
background: #f5f7fa;
border-radius: 8px;
flex-wrap: wrap;
.status-item {
display: flex;
align-items: center;
gap: 6px;
.status-label {
font-size: 12px;
color: #909399;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
}
}
// ---- Custom back-to-bottom button (with unread badge) ----
.custom-back-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: 20px;
background: linear-gradient(135deg, #409eff 0%, #337ecc 100%);
color: #fff;
font-size: 13px;
cursor: pointer;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.35);
transition: all 0.25s ease;
user-select: none;
z-index: 10;
&:hover {
transform: translateX(-50%) scale(1.05);
box-shadow: 0 4px 20px rgba(64, 158, 255, 0.5);
}
&:active {
transform: translateX(-50%) scale(0.97);
}
.btn-icon {
font-size: 14px;
font-weight: 700;
}
.btn-text {
white-space: nowrap;
}
.btn-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #f56c6c;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
&.has-unread {
animation: pulse-red 1.5s ease-in-out infinite;
}
}
@keyframes pulse-red {
0%,
100% {
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.35);
}
50% {
box-shadow:
0 2px 20px rgba(245, 108, 108, 0.6),
0 0 0 4px rgba(245, 108, 108, 0.15);
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
Back to Bottom Button
After appending messages, the list auto-scrolls to the bottom. unreadCount stays 0 and the back-to-bottom button won't appear.
Try: scroll up a bit first, then click "+ User Message" to see if it still auto-scrolls back.
Built-in back-to-bottom button with configurable appearance, or fully customize it with the #backToBottom slot.
Adjust button behavior and style via showBackButton, backButtonThreshold, backButtonPosition, btnColor / btnIconSize / btnLoading, etc. When autoScroll is off, the button automatically shows an unread badge — the slot context provides unreadCount (current unread count) and scrollToBottom() (clears unread on call).
<script setup lang="ts">
import type {
BubbleListInstance,
BubbleListScrollState
} from 'vue-element-plus-x/types/BubbleList';
interface MessageItem {
key: number;
role: 'user' | 'ai';
placement: 'start' | 'end';
content: string;
loading: boolean;
shape: 'round' | 'corner';
variant: string;
avatar: string;
avatarSize: string;
}
const bubbleListRef = ref<BubbleListInstance | null>(null);
// ---- Control switches ----
const useCustomSlot = ref(false);
const autoScrollEnabled = ref(true);
const alwaysShowScrollbar = ref(false);
// ---- Built-in button props (default mode only) ----
const btnLoading = ref(true);
const btnColor = ref('#409EFF');
const btnSize = ref(24);
const bottomValue = ref(20);
const leftValue = ref(50);
// ---- State ----
const scrollState = ref<BubbleListScrollState>('AT_BOTTOM');
const unreadCount = ref(0);
let nextKey = 100;
const backButtonPosition = computed(() => ({
bottom: `${bottomValue.value}%`,
left: `${leftValue.value}%`,
transform: 'translateX(-50%)'
}));
// ---- Initial data ----
const list = ref<MessageItem[]>(buildSeedList());
function buildSeedList(): MessageItem[] {
const messages: MessageItem[] = [];
for (let i = 0; i < 12; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
messages.push({
key: i + 1,
role,
placement,
content:
role === 'ai'
? '💖 Thank you for using Element Plus X ! Your support is our strongest motivation for open source ~'.repeat(
2
)
: `Hahaha, let me try message ${i + 1}`,
loading: false,
shape: 'corner',
variant: role === 'ai' ? 'filled' : 'outlined',
avatar:
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4',
avatarSize: '24px'
});
}
nextKey = 13;
return messages;
}
// ---- Actions ----
function appendMessage(role: 'user' | 'ai') {
list.value.push({
key: ++nextKey,
role,
placement: role === 'ai' ? 'start' : 'end',
content:
role === 'user'
? '📝 User appended message: a test message to observe how autoScroll affects scroll-to-bottom and unread behavior.'
: '🤖 AI appended reply: when autoScroll is off, this message triggers unread count +1 and the back-to-bottom button will show a red badge.',
loading: false,
shape: 'corner',
variant: role === 'ai' ? 'filled' : 'outlined',
avatar:
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4',
avatarSize: '24px'
});
}
function resetConversation() {
list.value = buildSeedList();
unreadCount.value = 0;
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
}
function handleScrollStateChange(state: BubbleListScrollState) {
scrollState.value = state;
}
function handleUnreadCountChange(count: number) {
unreadCount.value = count;
}
</script>
<template>
<div class="back-btn-demo">
<!-- ====== Control Panel ====== -->
<div class="control-panel">
<!-- Core switches -->
<div class="panel-section">
<div class="section-title">Core Controls</div>
<div class="control-row core-switch">
<span class="switch-label">
<strong>autoScroll</strong>
<span class="switch-desc">
{{
autoScrollEnabled
? 'Enabled — new messages auto-scroll to bottom, no unread'
: 'Disabled — accumulate unread, show badge'
}}
</span>
</span>
<el-switch
v-model="autoScrollEnabled"
active-text="On"
inactive-text="Off"
/>
</div>
<div class="control-row">
<span class="control-label">Custom slot (V2)</span>
<el-switch
v-model="useCustomSlot"
inactive-text="Default button"
active-text="Custom"
/>
</div>
<div class="control-row">
<span class="control-label">Scrollbar display</span>
<el-switch
v-model="alwaysShowScrollbar"
inactive-text="On hover"
active-text="Always show"
/>
</div>
</div>
<!-- Built-in button props (default mode only) -->
<template v-if="!useCustomSlot">
<div class="panel-section">
<div class="section-title">Built-in Button Style</div>
<div class="control-row">
<span class="control-label">Loading animation</span>
<el-switch
v-model="btnLoading"
inactive-text="Off"
active-text="On"
/>
</div>
<div class="control-row">
<span class="control-label">Button color</span>
<el-color-picker v-model="btnColor" size="default" />
</div>
<div class="control-row">
<span class="control-label">Icon size</span>
<el-slider v-model="btnSize" :min="16" :max="48" style="flex: 1" />
</div>
</div>
</template>
<!-- Positioning props (effective in both modes) -->
<div class="panel-section">
<div class="section-title">
Button Position
<el-tag
size="small"
type="success"
effect="plain"
style="margin-left: 6px; vertical-align: middle"
>
Also applies in slot mode
</el-tag>
</div>
<div class="control-row">
<span class="control-label">Bottom %</span>
<el-slider v-model="bottomValue" :min="0" :max="50" style="flex: 1" />
<span class="control-value">{{ bottomValue }}%</span>
</div>
<div class="control-row">
<span class="control-label">Horizontal %</span>
<el-slider v-model="leftValue" :min="0" :max="100" style="flex: 1" />
<span class="control-value">{{ leftValue }}%</span>
</div>
</div>
<!-- Custom mode tip -->
<div v-if="useCustomSlot" class="custom-tip">
<span class="tip-badge">V2</span>
<span
>When using the custom <code>#backToBottom</code> slot, icon / color /
size visual props are ignored. However,
<strong>backButtonPosition</strong> (positioning) is controlled by the
outer container — the position sliders above
<strong>still take effect in real time</strong>.</span
>
</div>
<!-- Action buttons -->
<el-divider style="margin: 10px 0" />
<div class="action-buttons">
<el-button
type="primary"
plain
size="small"
@click="appendMessage('user')"
>
+ User Message
</el-button>
<el-button
type="success"
plain
size="small"
@click="appendMessage('ai')"
>
+ AI Reply
</el-button>
<el-button size="small" @click="resetConversation">
Reset Session
</el-button>
</div>
<!-- Behavior hint -->
<div
class="behavior-hint"
:class="autoScrollEnabled ? 'hint-on' : 'hint-off'"
>
<div class="hint-icon">
{{ autoScrollEnabled ? '✅' : '⚠️' }}
</div>
<div class="hint-text">
<template v-if="autoScrollEnabled">
<strong>autoScroll is ON</strong><br />
After appending messages, the list auto-scrolls to the bottom.
<strong>unreadCount stays 0</strong> and the back-to-bottom button
won't appear.<br />
Try: scroll up a bit first, then click "+ User Message" to see if it
still auto-scrolls back.
</template>
<template v-else>
<strong>autoScroll is OFF</strong><br />
After appending messages, the list
<strong>does not auto-scroll</strong>. Unread count increments per
message, state becomes <code>HAS_NEW_MESSAGES</code>, and the
back-to-bottom button appears with a
<span class="badge-preview">red number badge</span>.<br />
Click the back-to-bottom button to clear unread and restore state to
<code>AT_BOTTOM</code>.
</template>
</div>
</div>
</div>
<!-- ====== BubbleList ====== -->
<div class="story-stage">
<BubbleList
ref="bubbleListRef"
:list="list"
:auto-scroll="autoScrollEnabled"
:always-show-scrollbar="alwaysShowScrollbar"
:show-back-button="true"
:btn-color="useCustomSlot ? undefined : btnColor"
:btn-loading="useCustomSlot ? undefined : btnLoading"
:back-button-position="backButtonPosition"
:btn-icon-size="useCustomSlot ? undefined : btnSize"
@scroll-state-change="handleScrollStateChange"
@unread-count-change="handleUnreadCountChange"
>
<!-- Custom back-to-bottom button (with unread badge) -->
<template v-if="useCustomSlot" #backToBottom="context">
<div
class="custom-back-btn"
:class="{ 'has-unread': context.unreadCount > 0 }"
@click="context.scrollToBottom(true)"
>
<span class="custom-back-btn__icon">↓</span>
<span class="custom-back-btn__text">
{{
context.unreadCount > 0
? `${context.unreadCount > 99 ? '99+' : context.unreadCount} new messages`
: 'Back to bottom'
}}
</span>
<span v-if="context.unreadCount > 0" class="custom-back-btn__badge">
{{ context.unreadCount > 99 ? '99+' : context.unreadCount }}
</span>
</div>
</template>
</BubbleList>
</div>
<!-- ====== Status bar ====== -->
<div class="status-bar">
<div class="status-item">
<span class="status-label">scrollState</span>
<el-tag
:type="
scrollState === 'AT_BOTTOM'
? 'success'
: scrollState === 'SCROLLED_UP'
? 'warning'
: 'danger'
"
size="small"
>
{{ scrollState }}
</el-tag>
</div>
<div class="status-item">
<span class="status-label">unreadCount</span>
<el-tag :type="unreadCount > 0 ? 'danger' : 'info'" size="small">
{{ unreadCount }}
</el-tag>
</div>
<div class="status-item">
<span class="status-label">autoScroll</span>
<el-tag :type="autoScrollEnabled ? 'success' : 'danger'" size="small">
{{ autoScrollEnabled ? 'ON' : 'OFF' }}
</el-tag>
</div>
<div class="status-item">
<span class="status-label">Button Mode</span>
<el-tag :type="useCustomSlot ? 'warning' : 'info'" size="small">
{{ useCustomSlot ? 'Custom Slot' : 'Built-in Default' }}
</el-tag>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.back-btn-demo {
display: flex;
flex-direction: column;
gap: 12px;
.story-stage {
height: 450px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
}
}
// ---- Control Panel ----
.control-panel {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid #e4e7ed;
border-radius: 12px;
padding: 14px;
.panel-section {
display: flex;
flex-direction: column;
gap: 6px;
& + .panel-section {
margin-top: 6px;
padding-top: 10px;
border-top: 1px dashed #dcdfe6;
}
}
.section-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 2px;
}
.control-row {
display: flex;
align-items: center;
gap: 10px;
.control-label {
font-size: 13px;
color: #606266;
min-width: 100px;
}
.control-value {
font-size: 12px;
color: #409eff;
min-width: 36px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
}
.core-switch {
background: #fafafa;
border-radius: 8px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
.switch-label {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #303133;
font-size: 14px;
}
.switch-desc {
font-size: 12px;
color: #909399;
}
}
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
// Behavior hint card
.behavior-hint {
margin-top: 4px;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.65;
display: flex;
gap: 8px;
&.hint-on {
background: #f0f9eb;
border: 1px solid #e1f3d8;
color: #67c23a;
}
&.hint-off {
background: #fef0f0;
border: 1px solid #fde2e2;
color: #f56c6c;
}
.hint-icon {
font-size: 18px;
flex-shrink: 0;
margin-top: 1px;
}
strong {
font-weight: 600;
}
code {
padding: 1px 5px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.06);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.badge-preview {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #f56c6c;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1;
vertical-align: middle;
}
}
// V2 custom tip
.custom-tip {
margin-top: 4px;
padding: 10px 12px;
border-radius: 8px;
background: linear-gradient(135deg, #f0f9eb 0%, #ecf5ff 100%);
border: 1px solid #e1f3d8;
font-size: 13px;
color: #606266;
line-height: 1.6;
.tip-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
background: #67c23a;
color: #fff;
font-size: 11px;
font-weight: 700;
margin-right: 6px;
vertical-align: middle;
}
code {
padding: 1px 5px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.06);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
}
}
// ---- Status bar ----
.status-bar {
display: flex;
gap: 16px;
padding: 10px 14px;
background: #f5f7fa;
border-radius: 8px;
flex-wrap: wrap;
.status-item {
display: flex;
align-items: center;
gap: 6px;
.status-label {
font-size: 12px;
color: #909399;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
}
}
// ---- Custom back-to-bottom button (with unread badge) ----
.custom-back-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
border-radius: 24px;
background: linear-gradient(135deg, #409eff 0%, #337ecc 100%);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 14px rgba(64, 158, 255, 0.35);
transition: all 0.25s ease;
user-select: none;
z-index: 10;
&:hover {
transform: translateX(-50%) translateY(-2px);
box-shadow: 0 6px 20px rgba(64, 158, 255, 0.5);
background: linear-gradient(135deg, #66b1ff 0%, #409eff 100%);
}
&:active {
transform: translateX(-50%) scale(0.96);
}
&.has-unread {
animation: pulse-red 1.5s ease-in-out infinite;
}
&__icon {
font-size: 16px;
font-weight: 700;
}
&__text {
white-space: nowrap;
}
&__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #f56c6c;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
}
@keyframes pulse-red {
0%,
100% {
box-shadow: 0 4px 14px rgba(64, 158, 255, 0.35);
}
50% {
box-shadow:
0 4px 20px rgba(245, 108, 108, 0.6),
0 0 0 4px rgba(245, 108, 108, 0.15);
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
Streaming Follow
When autoScroll is enabled (default), content height changes during streaming output will automatically follow to the bottom. After the user scrolls up, following is interrupted; scrolling back to the bottom resumes automatically — no extra configuration needed.
Custom Follow Strategy
For custom follow logic (e.g., only force follow for own messages), use the shouldFollowContent callback to take over decision making. The reason field in the callback parameter indicates the trigger source: own-message (own send), streaming (streaming increment), new-message (newly appended message).
<script setup lang="ts">
import type {
BubbleListInstance,
BubbleListScrollState
} from 'vue-element-plus-x/types/BubbleList';
interface MessageItem {
key: number;
role: 'user' | 'ai';
placement: 'start' | 'end';
content: string;
avatar: string;
avatarSize?: string;
avatarGap?: string;
shape?: string;
variant?: string;
}
const STREAM_TICK_MS = 50;
const STREAM_CHARS_PER_TICK = 6;
const STREAM_TOTAL_TICKS = 120;
const bubbleListRef = ref<BubbleListInstance | null>(null);
const bubbleItems = ref<MessageItem[]>([]);
const scrollState = ref<BubbleListScrollState>('AT_BOTTOM');
const unreadCount = ref(0);
const isStreaming = ref(false);
const streamTick = ref(0);
const emittedCharCount = ref(0);
const streamCharTotal = ref(0);
const round = ref(1);
const lastAction = ref(
'Click "Start Streaming" then scroll up to observe follow interruption; scroll back to bottom to observe auto-resume.'
);
let nextKey = 0;
let streamCharacters: string[] = [];
let streamOffset = 0;
let streamTimer: number | null = null;
function createMessage(
key: number,
role: 'user' | 'ai',
content: string
): MessageItem {
const isUser = role === 'user';
return {
key,
role,
placement: isUser ? 'end' : 'start',
content,
avatar: isUser
? 'https://avatars.githubusercontent.com/u/76239030?v=4'
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
avatarSize: '24px',
avatarGap: '12px',
shape: 'corner',
variant: isUser ? 'outlined' : 'filled'
};
}
function buildStreamingChars(currentRound: number): string[] {
const total = STREAM_CHARS_PER_TICK * STREAM_TOTAL_TICKS;
const paragraphs = Array.from({ length: STREAM_TOTAL_TICKS }, (_, idx) => {
return `Round ${currentRound} increment #${idx + 1}: continuously observe whether the bubble sticks to the bottom as content grows, whether scrolling up only accumulates unread without interrupting reading, and whether returning to bottom immediately resumes auto-follow. `;
});
return Array.from(paragraphs.join('')).slice(0, total);
}
function stopStreaming(reason = 'Streaming stopped.') {
if (streamTimer !== null) {
window.clearInterval(streamTimer);
streamTimer = null;
}
isStreaming.value = false;
lastAction.value = reason;
}
function buildSeedList(): MessageItem[] {
const list: MessageItem[] = [];
for (let i = 0; i < 14; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
list.push(
createMessage(
i + 1,
role,
role === 'ai'
? `Warm-up AI message ${i + 1}: This is seed data for streaming follow verification.`
: `Warm-up user message ${i + 1}`
)
);
}
nextKey = list.length;
round.value = 1;
streamTick.value = 0;
emittedCharCount.value = 0;
streamCharTotal.value = 0;
scrollState.value = 'AT_BOTTOM';
unreadCount.value = 0;
isStreaming.value = false;
lastAction.value =
'Click "Start Streaming" then scroll up to observe follow interruption; scroll back to bottom to observe auto-resume.';
streamCharacters = [];
streamOffset = 0;
return list;
}
function resetConversation() {
stopStreaming('Current streaming session has been reset.');
bubbleItems.value = buildSeedList();
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
}
function startStreaming() {
if (isStreaming.value) return;
const currentRound = round.value;
round.value += 1;
streamTick.value = 0;
emittedCharCount.value = 0;
streamOffset = 0;
// Append a user question first
bubbleItems.value.push(
createMessage(
++nextKey,
'user',
`Round ${currentRound} question: Please summarize the key upgrades of BubbleList in streaming output and pagination scenarios.`
)
);
// Prepare streaming characters
streamCharacters = buildStreamingChars(currentRound);
streamCharTotal.value = streamCharacters.length;
// Initial chunk
const initialChunk = streamCharacters
.slice(0, STREAM_CHARS_PER_TICK)
.join('');
streamOffset = initialChunk.length;
emittedCharCount.value = streamOffset;
streamTick.value = initialChunk.length > 0 ? 1 : 0;
// Append AI message
bubbleItems.value.push(createMessage(++nextKey, 'ai', initialChunk));
isStreaming.value = true;
lastAction.value = `Streaming in progress: appending ${STREAM_CHARS_PER_TICK} chars every ${STREAM_TICK_MS}ms, total ~${streamCharTotal.value} chars.`;
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
// Periodic append
streamTimer = window.setInterval(() => {
const currentItem = bubbleItems.value[bubbleItems.value.length - 1];
if (!currentItem || currentItem.role !== 'ai') {
stopStreaming('Streaming message lost, simulation terminated.');
return;
}
if (
streamOffset >= streamCharacters.length ||
streamTick.value >= STREAM_TOTAL_TICKS
) {
stopStreaming('Streaming output complete.');
return;
}
const nextChunk = streamCharacters
.slice(streamOffset, streamOffset + STREAM_CHARS_PER_TICK)
.join('');
if (!nextChunk) {
stopStreaming('Streaming output complete.');
return;
}
currentItem.content += nextChunk;
streamOffset += nextChunk.length;
emittedCharCount.value = streamOffset;
streamTick.value += 1;
if (
streamOffset >= streamCharacters.length ||
streamTick.value >= STREAM_TOTAL_TICKS
) {
stopStreaming('Streaming output complete.');
}
}, STREAM_TICK_MS);
}
function simulateInterrupt() {
if (!bubbleListRef.value) return;
bubbleListRef.value.scrollToTop(false);
lastAction.value =
'Scrolled to top — new chunks will pause follow. Return to bottom to auto-resume.';
}
function resumeFollow() {
bubbleListRef.value?.scrollToBottom(false);
lastAction.value =
'Returned to bottom (scrollToBottom) — streaming content will continue to follow.';
}
function handleScrollStateChange(state: BubbleListScrollState) {
scrollState.value = state;
}
function handleUnreadCountChange(count: number) {
unreadCount.value = count;
}
onMounted(() => {
resetConversation();
});
onUnmounted(() => {
stopStreaming('');
});
</script>
<template>
<div class="streaming-follow-demo">
<div class="tip-banner">
<span class="tip-icon">~</span>
<span
><strong>Core Experience Flow</strong>: ① Click "Start Streaming" → ②
Wait for AI output → ③ <strong>Scroll up</strong> to interrupt → ④
Observe unread count increase, list stops jumping → ⑤ Click "Return to
Bottom" → ⑥ Observe subsequent chunks auto-follow</span
>
</div>
<div class="toolbar-group">
<div class="btn-list">
<el-button
size="small"
type="primary"
plain
:disabled="isStreaming"
@click="startStreaming"
>
Start Streaming
</el-button>
<el-button
size="small"
type="warning"
plain
:disabled="!isStreaming"
@click="simulateInterrupt"
>
Scroll to Top (Simulate Scroll Up)
</el-button>
<el-button size="small" type="success" plain @click="resumeFollow">
Return to Bottom
</el-button>
<el-button
size="small"
type="danger"
plain
:disabled="!isStreaming"
@click="stopStreaming('Manually stopped')"
>
Stop Streaming
</el-button>
<el-button size="small" type="info" plain @click="resetConversation">
Reset Session
</el-button>
</div>
</div>
<div class="status-row">
<div class="status-chip">
<span>Scroll State</span
><strong :class="`state-${scrollState.toLowerCase()}`">{{
scrollState
}}</strong>
</div>
<div class="status-chip">
<span>Unread Count</span><strong>{{ unreadCount }}</strong>
</div>
<div class="status-chip">
<span>Stream Status</span
><strong :class="isStreaming ? 'streaming' : ''">{{
isStreaming ? 'In Progress' : 'Idle'
}}</strong>
</div>
<div class="status-chip">
<span>Current Round</span><strong>{{ Math.max(round - 1, 0) }}</strong>
</div>
<div class="status-chip">
<span>Chars Emitted</span
><strong>{{ emittedCharCount }}/{{ streamCharTotal }}</strong>
</div>
<div class="status-chip">
<span>Running Tick</span
><strong>{{ streamTick }}/{{ STREAM_TOTAL_TICKS }}</strong>
</div>
</div>
<div class="activity-bar">
<span>Last Action</span><strong>{{ lastAction }}</strong>
</div>
<div class="story-stage">
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
@scroll-state-change="handleScrollStateChange"
@unread-count-change="handleUnreadCountChange"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.streaming-follow-demo {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
.tip-banner {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
border-radius: 8px;
background: linear-gradient(135deg, #fef0f0 0%, #fdf6ec 100%);
border: 1px solid #fde2e2;
font-size: 13px;
color: #f56c6c;
line-height: 1.7;
.tip-icon {
font-size: 18px;
line-height: 1;
margin-top: 2px;
}
strong {
color: #303133;
}
}
.toolbar-group {
min-height: 0;
}
.btn-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #fff;
border: 1px solid #dbeafe;
span {
font-size: 12px;
color: #64748b;
line-height: 1;
}
strong {
font-size: 13px;
color: #0f172a;
line-height: 1;
&.state-at_bottom {
color: #67c23a;
}
&.state-scrolled_up {
color: #e6a23c;
}
&.state-has_new_messages {
color: #f56c6c;
}
&.streaming {
color: #409eff;
animation: blink 1.2s ease-in-out infinite;
}
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.activity-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 100%);
border: 1px solid #dbeafe;
span {
font-size: 12px;
color: #64748b;
}
strong {
font-size: 13px;
color: #1e3a8a;
}
}
.story-stage {
height: 420px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
Bidirectional Pagination Loading
Scrolling up to the top triggers @load-more-top; scrolling down to the bottom triggers @load-more-bottom. After data is ready, call loadMoreTopComplete() / loadMoreBottomComplete() to notify the component — scroll position is automatically corrected with no manual jump handling needed.
Use topStatus / bottomStatus props ({ type, text }) to control the boundary status display (loading / no-more), combined with #topStatus / #bottomStatus slots for custom UI.
<script setup lang="ts">
import type {
BubbleListBoundaryState,
BubbleListInstance,
BubbleListScrollState
} from 'vue-element-plus-x/types/BubbleList';
import { computed } from 'vue';
interface MessageItem {
key: number;
role: 'user' | 'ai';
placement: 'start' | 'end';
content: string;
avatar: string;
avatarSize?: string;
avatarGap?: string;
shape?: string;
variant?: string;
}
const MAX_HISTORY_BATCHES = 3;
const MAX_BOTTOM_BATCHES = 2;
const PAGE_SIZE = 6;
const bubbleListRef = ref<BubbleListInstance | null>(null);
const bubbleItems = ref<MessageItem[]>([]);
const scrollState = ref<BubbleListScrollState>('AT_BOTTOM');
const unreadCount = ref(0);
const historyBatchCount = ref(0);
const bottomBatchCount = ref(0);
const topTriggerCount = ref(0);
const bottomTriggerCount = ref(0);
const topLoading = ref(false);
const bottomLoading = ref(false);
const lastAction = ref(
'Scroll to the top manually to trigger history loading; scroll back to the bottom and continue scrolling down to load more messages.'
);
let nextKey = 0;
let topTimer: number | null = null;
let bottomTimer: number | null = null;
function createMessage(
key: number,
role: 'user' | 'ai',
content: string
): MessageItem {
const isUser = role === 'user';
return {
key,
role,
placement: isUser ? 'end' : 'start',
content,
avatar: isUser
? 'https://avatars.githubusercontent.com/u/76239030?v=4'
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
avatarSize: '24px',
avatarGap: '12px',
shape: 'corner',
variant: isUser ? 'outlined' : 'filled'
};
}
function createBatch(label: string, startRole: 'ai' | 'user'): MessageItem[] {
const batch: MessageItem[] = [];
for (let i = 0; i < PAGE_SIZE; i++) {
const role =
startRole === 'ai'
? i % 2 === 0
? 'ai'
: 'user'
: i % 2 === 0
? 'user'
: 'ai';
const step = i + 1;
batch.push(
createMessage(
nextKey + i + 1,
role,
role === 'user'
? `${label} user message ${step}: used to verify scroll position, back-to-bottom button, and pagination triggers.`
: `${label} AI reply ${step}: this message is intentionally varied in length to verify variable-height bubbles and auto-follow stability.`.repeat(
step % 3 === 0 ? 2 : 1
)
)
);
}
return batch;
}
function buildSeedList(): MessageItem[] {
const list: MessageItem[] = [];
for (let i = 0; i < 16; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
list.push(
createMessage(
i + 1,
role,
role === 'ai'
? `Initial AI message ${i + 1}`
: `Initial user message ${i + 1}`
)
);
}
nextKey = list.length;
scrollState.value = 'AT_BOTTOM';
unreadCount.value = 0;
historyBatchCount.value = 0;
bottomBatchCount.value = 0;
topTriggerCount.value = 0;
bottomTriggerCount.value = 0;
topLoading.value = false;
bottomLoading.value = false;
lastAction.value =
'Scroll to the top manually to trigger history loading; scroll back to the bottom and continue scrolling down to load more messages.';
return list;
}
function resetConversation() {
bubbleItems.value = buildSeedList();
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
}
// ---- Boundary status computed ----
const topStatus = computed<BubbleListBoundaryState | null>(() => {
if (topLoading.value)
return { type: 'loading', text: 'Loading earlier history messages...' };
if (historyBatchCount.value >= MAX_HISTORY_BATCHES)
return { type: 'no-more', text: 'All history messages have been loaded' };
return null;
});
const bottomStatus = computed<BubbleListBoundaryState | null>(() => {
if (bottomLoading.value)
return { type: 'loading', text: 'Loading more messages...' };
if (bottomBatchCount.value >= MAX_BOTTOM_BATCHES) {
return {
type: 'no-more',
text: 'All loadable messages have been displayed'
};
}
return null;
});
// ---- Pagination event handlers ----
function handleLoadMoreTop() {
topTriggerCount.value += 1;
if (topLoading.value) return;
topLoading.value = true;
const n = historyBatchCount.value + 1;
topTimer = window.setTimeout(() => {
if (historyBatchCount.value >= MAX_HISTORY_BATCHES) {
lastAction.value = 'All top history messages have been loaded.';
topLoading.value = false;
topTimer = null;
bubbleListRef.value?.loadMoreTopComplete();
return;
}
const items = createBatch(
`History batch ${n}`,
n % 2 === 0 ? 'user' : 'ai'
);
nextKey += items.length;
bubbleItems.value = [...items, ...bubbleItems.value];
historyBatchCount.value = n;
topLoading.value = false;
lastAction.value = `Top loading complete: history batch ${n} inserted.`;
topTimer = null;
nextTick(() => {
bubbleListRef.value?.loadMoreTopComplete();
});
}, 600);
}
function handleLoadMoreBottom() {
bottomTriggerCount.value += 1;
if (bottomLoading.value) return;
bottomLoading.value = true;
const n = bottomBatchCount.value + 1;
bottomTimer = window.setTimeout(() => {
if (bottomBatchCount.value >= MAX_BOTTOM_BATCHES) {
lastAction.value = 'All bottom messages have been loaded.';
bottomLoading.value = false;
bottomTimer = null;
bubbleListRef.value?.loadMoreBottomComplete();
return;
}
const items = createBatch(`Bottom batch ${n}`, n % 2 === 0 ? 'ai' : 'user');
nextKey += items.length;
bubbleItems.value = [...bubbleItems.value, ...items];
bottomBatchCount.value = n;
bottomLoading.value = false;
lastAction.value = `Bottom loading complete: batch ${n} appended.`;
bottomTimer = null;
nextTick(() => {
bubbleListRef.value?.loadMoreBottomComplete();
});
}, 600);
}
function handleScrollStateChange(state: BubbleListScrollState) {
scrollState.value = state;
}
function handleUnreadCountChange(count: number) {
unreadCount.value = count;
}
onMounted(() => {
resetConversation();
});
onUnmounted(() => {
if (topTimer) window.clearTimeout(topTimer);
if (bottomTimer) window.clearTimeout(bottomTimer);
});
</script>
<template>
<div class="bidirectional-demo">
<div class="tip-banner">
<span class="tip-icon">↕</span>
<span
>First <strong>scroll up</strong> to the top to trigger history loading,
then <strong>scroll down</strong> to the bottom to trigger more
messages. Observe the boundary status area switching between loading →
no-more.</span
>
</div>
<div class="toolbar-group">
<div class="btn-list">
<el-button
size="small"
type="primary"
plain
@click="bubbleListRef?.scrollToTop()"
>
Scroll to Top
</el-button>
<el-button
size="small"
type="primary"
plain
@click="bubbleListRef?.scrollToBottom()"
>
Scroll to Bottom
</el-button>
<el-button size="small" type="info" plain @click="resetConversation">
Reset Data
</el-button>
</div>
</div>
<div class="status-row">
<div class="status-chip">
<span>Scroll State</span
><strong :class="`state-${scrollState.toLowerCase()}`">{{
scrollState
}}</strong>
</div>
<div class="status-chip">
<span>Unread Count</span><strong>{{ unreadCount }}</strong>
</div>
<div class="status-chip">
<span>Top Triggers</span><strong>{{ topTriggerCount }}</strong>
</div>
<div class="status-chip">
<span>Bottom Triggers</span><strong>{{ bottomTriggerCount }}</strong>
</div>
<div class="status-chip">
<span>History Batches</span
><strong>{{ historyBatchCount }}/{{ MAX_HISTORY_BATCHES }}</strong>
</div>
<div class="status-chip">
<span>Bottom Batches</span
><strong>{{ bottomBatchCount }}/{{ MAX_BOTTOM_BATCHES }}</strong>
</div>
</div>
<div class="activity-bar">
<span>Last Action</span><strong>{{ lastAction }}</strong>
<small>
Top loading: {{ topLoading ? 'Yes' : 'No' }}, Bottom loading:
{{ bottomLoading ? 'Yes' : 'No' }}</small
>
</div>
<div class="story-stage">
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
:top-status="topStatus"
:bottom-status="bottomStatus"
@load-more-top="handleLoadMoreTop"
@load-more-bottom="handleLoadMoreBottom"
@scroll-state-change="handleScrollStateChange"
@unread-count-change="handleUnreadCountChange"
>
<template #topStatus="{ status }">
<div class="edge-status" :data-state="status.type">
<span class="edge-status__dot" /><span>{{ status.text }}</span>
</div>
</template>
<template #bottomStatus="{ status }">
<div class="edge-status" :data-state="status.type">
<span class="edge-status__dot" /><span>{{ status.text }}</span>
</div>
</template>
</BubbleList>
</div>
</div>
</template>
<style scoped lang="scss">
.bidirectional-demo {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
.tip-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: linear-gradient(135deg, #f0f9eb 0%, #ecf5ff 100%);
border: 1px solid #e1f3d8;
font-size: 13px;
color: #67c23a;
.tip-icon {
font-size: 18px;
font-weight: 700;
}
strong {
color: #303133;
}
}
.toolbar-group {
min-height: 0;
}
.btn-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #fff;
border: 1px solid #dbeafe;
span {
font-size: 12px;
color: #64748b;
line-height: 1;
}
strong {
font-size: 13px;
color: #0f172a;
line-height: 1;
&.state-at_bottom {
color: #67c23a;
}
&.state-scrolled_up {
color: #e6a23c;
}
&.state-has_new_messages {
color: #f56c6c;
}
}
}
.activity-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 100%);
border: 1px solid #dbeafe;
span {
font-size: 12px;
color: #64748b;
}
strong {
font-size: 13px;
color: #1e3a8a;
}
small {
color: #64748b;
font-size: 12px;
margin-left: 4px;
}
}
.story-stage {
min-height: 520px;
height: 520px;
padding: 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
display: flex;
}
.story-stage :deep(.elx-bubble-list) {
width: 100%;
height: 100%;
min-height: 0;
flex: 1 1 0;
overflow: hidden;
}
}
.edge-status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(148, 163, 184, 0.24);
font-size: 13px;
color: #475569;
&[data-state='loading'] {
color: #1d4ed8;
}
&[data-state='no-more'] {
color: #64748b;
}
}
.edge-status__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.8;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
Mixed Nodes
Regular messages continue using the default Bubble renderer; when an item contains a special identifier, it goes through the unified #item slot. This demo shows date nodes, history-divider nodes, and system-tip nodes coexisting on the same timeline.
In addition to bubble messages, the list can contain arbitrary node types (date dividers, system tips, history-load markers, etc.). Mark non-bubble nodes via the itemType property or a resolver function — matched items render through the #item slot while normal messages still use the default Bubble.
The slot context { item, index, itemType } distinguishes node types for different UI rendering. Special node heights are automatically measured in virtual scroll.
<script setup lang="ts">
import type {
BubbleListInstance,
BubbleListItemContext,
BubbleListScrollState
} from 'vue-element-plus-x/types/BubbleList';
interface TimelineItem {
key: number;
role: 'user' | 'ai' | 'system';
placement: 'start' | 'end';
content: string;
avatar: string;
avatarSize?: string;
itemType?: 'date-divider' | 'history-divider' | 'system-tip';
tone?: 'info' | 'success' | 'warning';
noStyle?: boolean;
}
const bubbleListRef = ref<BubbleListInstance | null>(null);
const bubbleItems = ref<TimelineItem[]>([]);
const scrollState = ref<BubbleListScrollState>('AT_BOTTOM');
const unreadCount = ref(0);
let nextKey = 0;
function createMessage(
key: number,
role: 'user' | 'ai',
content: string
): TimelineItem {
const isUser = role === 'user';
return {
key,
role,
placement: isUser ? 'end' : 'start',
content,
avatar: isUser
? 'https://avatars.githubusercontent.com/u/76239030?v=4'
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
avatarSize: '32px',
noStyle: false
};
}
function createNode(
itemType: TimelineItem['itemType'],
content: string,
tone: TimelineItem['tone'] = 'info'
): TimelineItem {
nextKey += 1;
return {
key: nextKey,
role: 'system',
itemType,
tone,
content,
placement: 'start',
avatar: '',
noStyle: true
};
}
function buildInitialList(): TimelineItem[] {
const base: TimelineItem[] = [];
for (let i = 0; i < 6; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
base.push(
createMessage(
i + 1,
role,
role === 'ai'
? `Initial AI message ${i + 1}: used to verify scroll stability with mixed nodes.`
: `Initial user message ${i + 1}`
)
);
}
nextKey = base.length;
base.splice(
2,
0,
createNode('history-divider', 'Above are history messages', 'info')
);
base.splice(
6,
0,
createNode('date-divider', 'April 16, 2026 14:30', 'success')
);
base.push(
createNode(
'system-tip',
'System notice: The conversation has switched to a new response strategy.',
'warning'
)
);
scrollState.value = 'AT_BOTTOM';
unreadCount.value = 0;
return base;
}
function resetConversation() {
bubbleItems.value = buildInitialList();
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
}
function appendAiMessage() {
bubbleItems.value.push(
createMessage(
++nextKey,
'ai',
'AI continues replying: verifying that auto-scroll and unread count remain accurate with special nodes mixed between regular messages.'
)
);
}
function appendDateDivider() {
bubbleItems.value.push(
createNode(
'date-divider',
`April 16, 2026 ${String(15 + (nextKey % 9))}:${String(nextKey % 60).padStart(2, '0')}`,
'success'
)
);
}
function appendHistoryDivider() {
bubbleItems.value.push(
createNode(
'history-divider',
'Switched to another segment of history',
'info'
)
);
}
function appendSystemTip() {
bubbleItems.value.push(
createNode(
'system-tip',
'System notice: The model is organizing a longer context. Please wait.',
'warning'
)
);
}
function handleScrollStateChange(state: BubbleListScrollState) {
scrollState.value = state;
}
function handleUnreadCountChange(count: number) {
unreadCount.value = count;
}
function resolveToneClass(context: BubbleListItemContext<TimelineItem>) {
if (context.itemType === 'date-divider') return 'is-success';
return context.item.tone === 'warning' ? 'is-warning' : 'is-info';
}
onMounted(() => {
resetConversation();
});
</script>
<template>
<div class="mixed-nodes-demo">
<div class="demo-note">
<div class="demo-title">Mixed Nodes / Unified Item Slot</div>
<p>
Regular messages continue using the default Bubble renderer; when an
item contains a special identifier, it goes through the unified
<code>#item</code> slot. This demo shows date nodes, history-divider
nodes, and system-tip nodes coexisting on the same timeline.
</p>
</div>
<div class="toolbar-group">
<div class="btn-list">
<el-button type="primary" plain @click="appendAiMessage">
Append AI Message
</el-button>
<el-button type="success" plain @click="appendDateDivider">
Insert Date Node
</el-button>
<el-button type="info" plain @click="appendHistoryDivider">
Insert History Divider
</el-button>
<el-button type="warning" plain @click="appendSystemTip">
Insert System Tip
</el-button>
<el-button type="info" plain @click="resetConversation">
Reset Timeline
</el-button>
</div>
</div>
<div class="status-row">
<div class="status-card">
<span>Scroll State</span
><strong :class="`state-${scrollState.toLowerCase()}`">{{
scrollState
}}</strong>
</div>
<div class="status-card">
<span>Unread Count</span><strong>{{ unreadCount }}</strong>
</div>
<div class="status-card">
<span>Node Count</span><strong>{{ bubbleItems.length }}</strong>
</div>
</div>
<div class="story-stage">
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
@scroll-state-change="handleScrollStateChange"
@unread-count-change="handleUnreadCountChange"
>
<template #item="context">
<div class="timeline-node" :class="[resolveToneClass(context)]">
<div
v-if="context.itemType === 'history-divider'"
class="timeline-node__history-wrap"
>
<span class="timeline-node__line" /><span
class="timeline-node__text"
>{{ context.item.content }}</span
><span class="timeline-node__line" />
</div>
<span
v-else-if="context.itemType === 'date-divider'"
class="timeline-node__pill"
>{{ context.item.content }}</span
>
<div v-else class="timeline-node__system-wrap">
<span class="timeline-node__icon">i</span
><span class="timeline-node__text">{{
context.item.content
}}</span>
</div>
</div>
</template>
</BubbleList>
</div>
</div>
</template>
<style scoped lang="scss">
.mixed-nodes-demo {
display: flex;
flex-direction: column;
gap: 14px;
.demo-note {
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 100%);
border: 1px solid #dbeafe;
p {
margin: 8px 0 0;
color: #475569;
line-height: 1.6;
}
code {
padding: 1px 5px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.06);
font-family: ui-monospace, monospace;
font-size: 12px;
}
}
.demo-title {
font-weight: 700;
color: #1e3a8a;
font-size: 15px;
}
.toolbar-group {
display: grid;
gap: 10px;
}
.btn-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.status-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.status-card {
display: grid;
gap: 4px;
padding: 10px 14px;
border-radius: 12px;
background: #fff;
border: 1px solid #e5e7eb;
span {
font-size: 12px;
color: #64748b;
}
strong {
font-size: 14px;
color: #0f172a;
&.state-at_bottom {
color: #67c23a;
}
&.state-scrolled_up {
color: #e6a23c;
}
&.state-has_new_messages {
color: #f56c6c;
}
}
}
.story-stage {
height: 450px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
}
.timeline-node {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 40px;
color: #475569;
}
.timeline-node__history-wrap,
.timeline-node__system-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
}
.timeline-node__line {
flex: 1;
max-width: 120px;
height: 1px;
background: currentColor;
opacity: 0.25;
}
.timeline-node__pill,
.timeline-node__text {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.24);
text-align: center;
}
.timeline-node__icon {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: currentColor;
color: #fff;
}
.is-info {
color: #475569;
}
.is-success {
color: #0f766e;
}
.is-warning {
color: #b45309;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
Slot Customization
Fully take over list rendering with 9 slots: #avatar, #header, #content, #footer, #loading control each bubble's parts; #backToBottom customizes the back-to-bottom button (with unread badge); #topStatus / #bottomStatus customize boundary status areas; #item renders non-bubble type nodes.
<script setup lang="ts">
import type {
BubbleListBoundaryState,
BubbleListItemProps,
BubbleListProps
} from 'vue-element-plus-x/types/BubbleList';
import {
ArrowDown,
Bell,
Bottom,
DocumentCopy,
Refresh,
Search,
Star,
Top
} from '@element-plus/icons-vue';
type listType = BubbleListItemProps & {
key: number;
role: 'user' | 'ai' | 'system';
};
// Example call
const bubbleItems = ref<BubbleListProps<listType>['list']>(
generateFakeItems(16)
);
const avatar = ref('https://avatars.githubusercontent.com/u/76239030?v=4');
const avartAi = ref(
'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
);
const switchValue = ref(false);
const loading = ref(false);
// Boundary state (for topStatus / bottomStatus slots)
const topStatus = ref<BubbleListBoundaryState | null>(null);
const bottomStatus = ref<BubbleListBoundaryState | null>(null);
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
messages.push({
key,
role,
placement,
noStyle: true // If you don't want to use default bubble styles
});
}
return messages;
}
// Set loading for a specific item
function setLoading(loading: boolean) {
bubbleItems.value[bubbleItems.value.length - 1].loading = loading;
bubbleItems.value[bubbleItems.value.length - 2].loading = loading;
}
/** Insert a system notice (rendered via #item slot) */
function addNotice() {
bubbleItems.value.push({
key: bubbleItems.value.length + 1,
role: 'system',
placement: 'start',
type: 'notice'
} as listType);
}
/** Simulate top load more */
function triggerTopLoad() {
topStatus.value = { type: 'loading', text: 'Loading earlier messages...' };
setTimeout(() => {
bubbleItems.value.unshift(
{ key: Date.now(), role: 'ai', placement: 'start', noStyle: true },
{ key: Date.now() + 1, role: 'user', placement: 'end', noStyle: true }
);
topStatus.value = null;
}, 1500);
}
/** Simulate bottom load more */
function triggerBottomLoad() {
bottomStatus.value = { type: 'loading', text: 'Loading more messages...' };
setTimeout(() => {
bubbleItems.value.push({
key: Date.now(),
role: 'ai',
placement: 'start',
noStyle: true
});
bottomStatus.value = null;
}, 1500);
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<!-- Control area -->
<div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: center">
<span>Dynamic content setting <el-switch v-model="switchValue" /></span>
<span
>Custom loading
<el-switch
v-model="loading"
@change="(value: any) => setLoading(value as boolean)"
/>
</span>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: center">
<el-button type="primary" @click="addNotice">
+ System Notice (#item)
</el-button>
<el-button type="primary" :icon="Top" @click="triggerTopLoad">
Top Load
</el-button>
<el-button type="primary" :icon="Bottom" @click="triggerBottomLoad">
Bottom Load
</el-button>
<el-button @click="() => bubbleItems.unshift(...generateFakeItems(3))">
+3 Messages (observe back-to-bottom button)
</el-button>
</div>
<div class="story-stage">
<BubbleList
:list="bubbleItems"
:top-status="topStatus"
:bottom-status="bottomStatus"
show-back-button
>
<!-- ====== 1. #avatar Custom avatar ====== -->
<template #avatar="{ item }">
<div class="avatar-wrapper">
<img :src="item.role === 'ai' ? avartAi : avatar" alt="avatar" />
</div>
</template>
<!-- ====== 2. #header Custom header ====== -->
<template #header="{ item }">
<div class="header-wrapper">
<div class="header-name">
{{ item.role === 'ai' ? 'Element Plus X 🍧' : '🧁 User' }}
</div>
</div>
</template>
<!-- ====== 3. #content Custom bubble content ====== -->
<template #content="{ item }">
<div class="content-wrapper">
<div class="content-text">
{{
item.role === 'ai'
? `${switchValue ? `#ai-${item.key}:` : ''} 💖 Thank you for using Element Plus X ! Your support is our strongest motivation for open source ~`
: `${switchValue ? `#user-${item.key}:` : ''}Hahaha, let me try`
}}
</div>
</div>
</template>
<!-- ====== 4. #footer Custom footer action bar ====== -->
<template #footer="{ item }">
<div class="footer-wrapper">
<div class="footer-container">
<el-button type="info" :icon="Refresh" size="small" circle />
<el-button type="success" :icon="Search" size="small" circle />
<el-button type="warning" :icon="Star" size="small" circle />
<el-button
color="#626aef"
:icon="DocumentCopy"
size="small"
circle
/>
</div>
<div class="footer-time">
{{ item.role === 'ai' ? '2:32 PM' : '2:33 PM' }}
</div>
</div>
</template>
<!-- ====== 5. #loading Custom loading animation ====== -->
<template #loading="{ item }">
<div class="loading-container">
<span>#{{ item.role }}-{{ item.key }}:</span>
<span>I</span>
<span>am</span>
<span>custom</span>
<span>loading</span>
<span>content</span>
<span>oh</span>
<span>~</span>
</div>
</template>
<!-- ====== 6. #backToBottom Custom back-to-bottom button (with unread badge) ====== -->
<template #backToBottom="{ unreadCount: uc, scrollToBottom }">
<div class="custom-back-btn" @click="scrollToBottom(false)">
<el-icon :size="14">
<ArrowDown />
</el-icon>
<span>{{ uc > 0 ? `${uc} new messages` : 'Back to bottom' }}</span>
<span v-if="uc > 0" class="unread-badge">{{
uc > 99 ? '99+' : uc
}}</span>
</div>
</template>
<!-- ====== 7. #topStatus Custom top boundary status ====== -->
<template #topStatus="{ status }">
<div class="boundary-custom boundary-top">
<el-icon
v-if="status.type === 'loading'"
class="is-loading"
:size="14"
>
<Refresh />
</el-icon>
<span>{{ status.text || `Top ${status.type}` }}</span>
<el-tag size="small" type="info" effect="plain">
#topStatus
</el-tag>
</div>
</template>
<!-- ====== 8. #bottomStatus Custom bottom boundary status ====== -->
<template #bottomStatus="{ status }">
<div class="boundary-custom boundary-bottom">
<el-icon
v-if="status.type === 'loading'"
class="is-loading"
:size="14"
>
<Refresh />
</el-icon>
<span>{{ status.text || `Bottom ${status.type}` }}</span>
<el-tag size="small" type="info" effect="plain">
#bottomStatus
</el-tag>
</div>
</template>
<!-- ====== 9. #item Non-bubble custom rendering ====== -->
<template #item="{ index, itemType }">
<div class="custom-notice-item">
<el-icon :size="16" color="#e6a23c">
<Bell />
</el-icon>
<span
>[{{ itemType }}] System Notice — This is custom rendered content
via #item slot (index={{ index }})</span
>
</div>
</template>
</BubbleList>
</div>
</div>
</template>
<style scoped lang="less">
.story-stage {
height: 450px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
}
/* ── 1. avatar ── */
.avatar-wrapper {
width: 40px;
height: 40px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
/* ── 2. header ── */
.header-wrapper {
.header-name {
font-size: 14px;
color: #979797;
}
}
/* ── 3. content ── */
.content-wrapper {
.content-text {
font-size: 14px;
color: #333;
padding: 12px;
background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
/* ── 4. footer ── */
.footer-wrapper {
display: flex;
align-items: center;
gap: 10px;
.footer-time {
font-size: 12px;
margin-top: 3px;
}
}
.footer-container {
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
/* ── 5. loading ── */
.loading-container {
font-size: 14px;
color: #333;
padding: 12px;
background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.loading-container span {
display: inline-block;
margin-left: 8px;
}
@keyframes bounce {
0%,
100% {
transform: translateY(5px);
}
50% {
transform: translateY(-5px);
}
}
.loading-container span:nth-child(4n) {
animation: bounce 1.2s ease infinite;
}
.loading-container span:nth-child(4n + 1) {
animation: bounce 1.2s ease infinite;
animation-delay: 0.3s;
}
.loading-container span:nth-child(4n + 2) {
animation: bounce 1.2s ease infinite;
animation-delay: 0.6s;
}
.loading-container span:nth-child(4n + 3) {
animation: bounce 1.2s ease infinite;
animation-delay: 0.9s;
}
/* ── 6. backToBottom custom back-to-bottom button ── */
.custom-back-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transition: all 0.25s ease;
user-select: none;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(102, 126, 234, 0.55);
}
}
.unread-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #f56c6c;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
animation: badge-pulse 1.5s ease-in-out infinite;
}
@keyframes badge-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.55);
}
50% {
box-shadow: 0 0 0 5px rgba(245, 108, 108, 0);
}
}
/* ── 7 & 8. topStatus / bottomStatus ── */
.boundary-custom {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
color: #606266;
&.boundary-top {
background: linear-gradient(90deg, #ecf5ff 0%, transparent 100%);
border: 1px dashed #b3d8ff;
}
&.boundary-bottom {
background: linear-gradient(90deg, transparent 0%, #ecf5ff 100%);
border: 1px dashed #b3d8ff;
}
.el-icon {
color: #409eff;
}
}
/* ── 9. item non-bubble custom item ── */
.custom-notice-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
margin: 4px 0;
border-radius: 8px;
background: linear-gradient(90deg, #fdf6ec 0%, #faecd8 100%);
border-left: 3px solid #e6a23c;
font-size: 13px;
color: #996633;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
Theme Overrides
Override BubbleList theme variables via ConfigProvider.themeOverrides. See the full variable list and copyable template:
Override BubbleList --elx-* variables via ConfigProvider.themeOverrides and also style Bubble for a stronger contrast.
<script setup lang="ts">
import { computed, ref } from 'vue';
const enabled = ref(true);
const list = ref(
Array.from({ length: 18 }).map((_, i) => ({
content: `Message ${i + 1}: used to demonstrate scrolling and the back button.`,
placement: i % 2 === 0 ? 'start' : 'end'
}))
);
const themeOverrides = computed(() => {
if (!enabled.value) return {};
return {
common: {
'color-primary': '#f97316',
'border-color': 'rgba(249, 115, 22, 0.38)',
'fill-color': 'rgba(249, 115, 22, 0.10)',
'box-shadow': '0 18px 54px rgba(249, 115, 22, 0.22)'
},
components: {
BubbleList: {
'bubble-list-max-height': '260px',
'bubble-list-btn-size': '38px'
},
Bubble: {
'bubble-content-max-width': '420px',
'bubble-bg':
'linear-gradient(135deg, rgba(249, 115, 22, 0.16), rgba(245, 158, 11, 0.10))',
'bubble-border-color': 'rgba(249, 115, 22, 0.30)',
'bubble-text-color': 'rgba(15, 23, 42, 0.86)',
'bubble-radius': '18px',
'bubble-padding-y': '14px',
'bubble-padding-x': '18px',
'bubble-shadow': '0 18px 52px rgba(249, 115, 22, 0.18)',
'bubble-dot-color': '#f97316'
}
}
};
});
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<div
style="display: flex; align-items: center; justify-content: space-between"
>
<div>
Scroll the list and observe max-height and back-button size changes.
</div>
<button
type="button"
style="
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.02);
cursor: pointer;
"
@click="enabled = !enabled"
>
{{ enabled ? 'Disable theme overrides' : 'Enable theme overrides' }}
</button>
</div>
<ConfigProvider apply-to="self" :theme-overrides="themeOverrides">
<div
style="
height: 360px;
padding: 14px;
border-radius: 16px;
border: 1px solid var(--elx-border-color);
background:
radial-gradient(
1200px 280px at 0% 0%,
rgba(249, 115, 22, 0.22),
transparent 60%
),
radial-gradient(
900px 240px at 100% 20%,
rgba(245, 158, 11, 0.14),
transparent 55%
),
rgba(0, 0, 0, 0.02);
box-shadow: var(--elx-box-shadow);
"
>
<BubbleList :list="list" always-show-scrollbar />
</div>
</ConfigProvider>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
Using with x-markdown-vue
Starting from v2.0.0, the component library no longer bundles XMarkdown / XMarkdownAsync. For Markdown rendering, use the standalone package x-markdown-vue, or see the dedicated docs: XMarkdown.
Installation
pnpm add x-markdown-vue
pnpm add katex
pnpm add shiki shiki-stream2
3
💡 Tip
If you need code block syntax highlighting, please install shiki and shiki-stream. Otherwise, you may see this error in the console: Streaming highlighter initialization failed: Error: Failed to load shiki-stream module
Basic Usage
Supports list rendering with formulas, code blocks, and task lists, and simulates real-time AI streaming Markdown output.
V2 Upgrade Notes
- Streaming Follow: V2 automatically sticks to the bottom during streaming output (as content grows taller). When the user scrolls up, following is interrupted; returning to the bottom resumes it automatically. V1 required manual scroll management.
- Virtual Scroll Compatibility: V2 virtual scroll + dynamic height measurement automatically re-measures item height when streaming content grows, preventing scroll position jumps.
- State Awareness: The
scroll-state-changeandunread-count-changeevents let you track scroll state changes in real time during streaming output.
<script setup lang="ts">
import type {
BubbleListInstance,
BubbleListScrollState
} from 'vue-element-plus-x/types/BubbleList';
import 'katex/dist/katex.min.css';
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
interface MessageItem {
key: number;
role: 'user' | 'ai' | 'system';
placement: 'start' | 'end';
content: string;
avatar: string;
}
// ---- Streaming related ----
const STREAM_TICK_MS = 70;
const STREAM_CHARS_PER_TICK = 8;
const bubbleListRef = ref<BubbleListInstance | null>(null);
const bubbleItems = ref<MessageItem[]>([]);
const scrollState = ref<BubbleListScrollState>('AT_BOTTOM');
const unreadCount = ref(0);
const isStreaming = ref(false);
const emittedCharCount = ref(0);
const streamCharTotal = ref(0);
const round = ref(1);
const lastAction = ref(
'Click "Start Streaming Markdown" to observe AI messages rendering incrementally with scroll following.'
);
let nextKey = 0;
let streamCharacters: string[] = [];
let streamOffset = 0;
let streamTimer: number | null = null;
function buildStaticMessages(): MessageItem[] {
return [
{
key: 1,
role: 'ai',
placement: 'start',
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
content: `### Inline Formulas
1. Euler's formula: $e^{i\\pi} + 1 = 0$
2. Quadratic formula: $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$
### [] Wrapped Formula
\\[ e^{i\\pi} + 1 = 0 \\]
\\[\\boxed{boxed content}\\]`
},
{
key: 2,
role: 'user',
placement: 'end',
avatar: 'https://avatars.githubusercontent.com/u/1?s=40&v=4',
content: 'How can I help you?'
},
{
key: 3,
role: 'ai',
placement: 'start',
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
content: `### Block Formulas & Code Block
Fourier Transform:
$$
F(\\omega) = \\int_{-\\infty}^{\\infty} f(t) e^{-i\\omega t} dt
$$
Matrix Multiplication:
$$
\\begin{bmatrix}
a & b \\\\
c & d
\\end{bmatrix}
\\begin{bmatrix}
x \\\\
y
\\end{bmatrix}
=
\\begin{bmatrix}
ax + by \\\\
cx + dy
\\end{bmatrix}
$$
Task List:
- [x] Add some task
- [ ] Do some task
\`\`\`typescript
const greeting: string = "Hello World";
console.log(greeting);
\`\`\``
}
];
}
function buildStreamingMarkdown(currentRound: number): string {
return `### Round ${currentRound} Streaming Markdown Reply
This is a **streaming output + Markdown rendering** demo to verify V2 upgrade capabilities:
- Whether stick-to-bottom remains stable as output content grows taller
- Whether scrolling up only accumulates unread count without forcing a jump back
- Whether subsequent chunks continue auto-following after returning to the bottom
#### Structured Summary
1. BubbleList handles virtual scrolling and follow strategy.
2. MarkdownRenderer handles rich text rendering (headings, lists, code, formulas).
3. Together they cover long replies in real chat scenarios.
#### Code Snippet
\`\`\`ts
type StreamChunk = {
text: string;
index: number;
};
const followWhenAtBottom = (distance: number) => distance <= 4;
\`\`\`
#### Math Formula
$$
F(\\omega) = \\int_{-\\infty}^{\\infty} f(t)e^{-i\\omega t}\\,dt
$$
> Conclusion: When messages are incrementally updated, scroll boundary detection needs to account for status area height and tolerance.`;
}
function stopStreaming(reason = 'Streaming Markdown output stopped.') {
if (streamTimer !== null) {
window.clearInterval(streamTimer);
streamTimer = null;
}
isStreaming.value = false;
lastAction.value = reason;
}
function resetConversation() {
stopStreaming('Current Markdown streaming session has been reset.');
bubbleItems.value = buildStaticMessages();
nextKey = 3;
round.value = 1;
emittedCharCount.value = 0;
streamCharTotal.value = 0;
scrollState.value = 'AT_BOTTOM';
unreadCount.value = 0;
lastAction.value =
'Click "Start Streaming Markdown" to observe AI messages rendering incrementally with scroll following.';
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
}
function startStreaming() {
if (isStreaming.value) return;
const currentRound = round.value;
round.value += 1;
emittedCharCount.value = 0;
streamOffset = 0;
// Append a user question first
nextKey += 1;
bubbleItems.value.push({
key: nextKey,
role: 'user',
placement: 'end',
avatar: 'https://avatars.githubusercontent.com/u/1?s=40&v=4',
content: `Please explain the round ${currentRound} BubbleList streaming follow verification conclusion in Markdown.`
});
// Prepare streaming Markdown content
const markdown = buildStreamingMarkdown(currentRound);
streamCharacters = Array.from(markdown);
streamCharTotal.value = streamCharacters.length;
// Initial chunk
const initialChunk = streamCharacters
.slice(0, STREAM_CHARS_PER_TICK)
.join('');
streamOffset = initialChunk.length;
emittedCharCount.value = streamOffset;
// Append AI message (initial content)
nextKey += 1;
bubbleItems.value.push({
key: nextKey,
role: 'ai',
placement: 'start',
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
content: initialChunk
});
isStreaming.value = true;
lastAction.value = `Streaming Markdown in progress: appending ${STREAM_CHARS_PER_TICK} characters every ${STREAM_TICK_MS}ms.`;
nextTick(() => {
bubbleListRef.value?.scrollToBottom(false);
});
// Timed append
streamTimer = window.setInterval(() => {
const currentItem = bubbleItems.value[bubbleItems.value.length - 1];
if (!currentItem || currentItem.role !== 'ai') {
stopStreaming('Streaming message lost, simulation terminated.');
return;
}
if (streamOffset >= streamCharacters.length) {
stopStreaming('Streaming Markdown output complete.');
return;
}
const nextChunk = streamCharacters
.slice(streamOffset, streamOffset + STREAM_CHARS_PER_TICK)
.join('');
if (!nextChunk) {
stopStreaming('Streaming Markdown output complete.');
return;
}
currentItem.content += nextChunk;
streamOffset += nextChunk.length;
emittedCharCount.value = streamOffset;
if (streamOffset >= streamCharacters.length) {
stopStreaming('Streaming Markdown output complete.');
}
}, STREAM_TICK_MS);
}
function handleScrollStateChange(state: BubbleListScrollState) {
scrollState.value = state;
}
function handleUnreadCountChange(count: number) {
unreadCount.value = count;
}
onMounted(() => {
resetConversation();
});
onUnmounted(() => {
stopStreaming('Component unmounted, Markdown streaming timer cleaned up.');
});
</script>
<template>
<div class="markdown-demo-container">
<div class="tip-banner">
<span class="tip-icon">▶</span>
<span
>Click "Start Streaming Markdown" first, then try
<strong>scrolling up</strong> to interrupt following, then click "Scroll
to Bottom" to observe auto-resume.</span
>
</div>
<div class="toolbar">
<div class="btn-list">
<el-button
type="primary"
plain
:disabled="isStreaming"
@click="startStreaming"
>
Start Streaming Markdown
</el-button>
<el-button
type="warning"
plain
:disabled="!isStreaming"
@click="bubbleListRef?.scrollToTop(false)"
>
Simulate Scroll Up
</el-button>
<el-button
type="success"
plain
@click="bubbleListRef?.scrollToBottom(false)"
>
Scroll to Bottom
</el-button>
<el-button
type="danger"
plain
:disabled="!isStreaming"
@click="stopStreaming('Manually stopped')"
>
Stop Output
</el-button>
<el-button type="info" plain @click="resetConversation">
Reset Session
</el-button>
</div>
</div>
<div class="status-row">
<div class="status-chip">
<span>Scroll State</span>
<strong :class="`state-${scrollState.toLowerCase()}`">{{
scrollState
}}</strong>
</div>
<div class="status-chip">
<span>Unread Count</span>
<strong>{{ unreadCount }}</strong>
</div>
<div class="status-chip">
<span>Stream Status</span>
<strong :class="isStreaming ? 'streaming' : ''">{{
isStreaming ? 'Outputting...' : 'Idle'
}}</strong>
</div>
<div class="status-chip">
<span>Chars Emitted</span>
<strong>{{ emittedCharCount }}/{{ streamCharTotal }}</strong>
</div>
<div class="status-chip">
<span>Current Round</span>
<strong>{{ Math.max(round - 1, 0) }}</strong>
</div>
</div>
<div class="activity-bar">
<span>Last Action</span>
<strong>{{ lastAction }}</strong>
</div>
<div class="story-stage">
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
@scroll-state-change="handleScrollStateChange"
@unread-count-change="handleUnreadCountChange"
>
<template #content="{ item }">
<div v-if="item.role === 'ai'" class="markdown-content-wrapper">
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="item.content || ''"
/>
<pre v-else>{{ item.content }}</pre>
</div>
<span v-else>{{ item.content }}</span>
</template>
</BubbleList>
</div>
</div>
</template>
<style scoped lang="scss">
.markdown-demo-container {
display: flex;
flex-direction: column;
gap: 12px;
.story-stage {
height: 450px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.tip-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: linear-gradient(135deg, #ecf5ff 0%, #fef0f0 100%);
border: 1px solid #d9ecff;
font-size: 13px;
color: #409eff;
.tip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #409eff;
color: #fff;
font-size: 11px;
flex-shrink: 0;
}
strong {
color: #f56c6c;
}
}
.toolbar {
.btn-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #fff;
border: 1px solid #e4e7ed;
span {
font-size: 12px;
color: #909399;
}
strong {
font-size: 13px;
color: #303133;
}
.state-at_bottom {
color: #67c23a;
}
.state-scrolled_up {
color: #e6a23c;
}
.state-has_new_messages {
color: #f56c6c;
}
.streaming {
color: #409eff;
animation: blink 1s ease-in-out infinite;
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.activity-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 100%);
border: 1px solid #dbeafe;
span {
font-size: 12px;
color: #909399;
}
strong {
font-size: 13px;
color: #1e3a8a;
}
}
}
.markdown-content-wrapper {
word-break: break-word;
color: #24292e;
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin-top: 16px;
margin-bottom: 8px;
font-weight: 600;
line-height: 1.25;
&:first-child {
margin-top: 0;
}
}
:deep(p) {
margin-top: 0;
margin-bottom: 8px;
line-height: 1.6;
&:last-child {
margin-bottom: 0;
}
}
:deep(ul),
:deep(ol) {
padding-left: 20px;
margin-top: 0;
margin-bottom: 8px;
ul,
ol {
margin-top: 4px;
margin-bottom: 0;
}
}
:deep(ul) {
list-style-type: disc;
}
:deep(ol) {
list-style-type: decimal;
}
:deep(li) {
margin: 4px 0;
line-height: 1.6;
&.task-list-item {
list-style-type: none;
padding-left: 0;
display: flex;
align-items: flex-start;
margin-left: -20px;
input[type='checkbox'] {
margin: 5px 8px 0 0;
flex-shrink: 0;
}
}
}
:deep(a) {
color: #0366d6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(code):not(pre code) {
background-color: rgba(27, 31, 35, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 85%;
}
:deep(blockquote) {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 8px 0;
}
:deep(hr) {
height: 0.25em;
padding: 0;
margin: 16px 0;
background-color: #e1e4e8;
border: 0;
}
:deep(table) {
display: block;
width: 100%;
overflow: auto;
margin-top: 0;
margin-bottom: 16px;
border-collapse: collapse;
th,
td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
&:nth-child(2n) {
background-color: #f6f8fa;
}
}
}
}
:deep(.x-md-code-block) {
pre {
background-color: #f6f8fa !important;
padding: 16px;
border-radius: 8px;
overflow: auto;
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.5;
.line {
display: block;
min-height: 1rem;
}
}
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
Fog Effect
By setting the enable-animate property, you can achieve a typewriter fog effect:
<script setup>
import { ref } from 'vue';
import { BubbleList } from 'vue-element-plus-x';
import { MarkdownRenderer } from 'x-markdown-vue';
import 'x-markdown-vue/style';
const list = ref([
{ content: '', placement: 'start' } // Streaming content will be updated dynamically
]);
</script>
<template>
<BubbleList :list="list">
<template #content="{ item }">
<MarkdownRenderer :markdown="item.content" :enable-animate="true" />
</template>
</BubbleList>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
list | Array | Yes | - | Message array. Each object is passed through to the built-in Bubble component and supports all Bubble props. |
autoScroll | Boolean | No | true | Whether to auto-scroll to the bottom when new messages are added. When disabled, new messages accumulate as unread count. |
maxHeight | String | No | - | Maximum list height. Defaults to filling the parent container. |
virtual | Boolean | No | true | Whether to enable virtual scrolling (based on virtua/vue). Recommended to keep on for large datasets. |
smoothScroll | Boolean | No | false | Whether programmatic scrolling uses smooth animation by default. |
itemKey | string | Function | No | 'key' | Unique node identifier. Can be a field name or (item, index) => key function. |
itemType | string | Function | No | - | Non-bubble node type identifier. When matched, renders via the #item slot. Can be a field name or function. |
showBackButton | Boolean | No | true | Whether to show the back-to-bottom button. |
backButtonThreshold | Number | No | 80 | Threshold (px from bottom) to trigger showing the back-to-bottom button. |
backButtonPosition | Object | No | { bottom: '20px', left: 'calc(50% - 19px)' } | CSS positioning of the back-to-bottom button. Supports top / right / bottom / left / transform. |
backButtonSmoothScroll | Boolean | No | true | Whether clicking the back-to-bottom button uses smooth scrolling. |
alwaysShowScrollbar | Boolean | No | false | Whether to always show the scrollbar. |
btnLoading | Boolean | No | true | Whether to show loading state on the built-in back-to-bottom button. |
btnColor | String | No | '#409EFF' | Color of the built-in back-to-bottom button. |
btnIconSize | Number | No | 24 | Icon size (px) of the built-in back-to-bottom button. |
topStatus | { type, text? } | No | - | Top boundary status. type can be idle / loading / no-more / error. |
bottomStatus | { type, text? } | No | - | Bottom boundary status, same as topStatus. |
loadMoreTopThreshold | Number | No | 100 | Distance from top (px) to trigger @load-more-top. |
loadMoreBottomThreshold | Number | No | 100 | Distance from bottom (px) to trigger @load-more-bottom. |
shouldFollowContent | Function | No | - | Custom content follow strategy. Return true to scroll to bottom, false to accumulate unread. Callback params include reason / item / index / scrollState / unreadCount / autoScroll. |
Events
| Event Name | Parameters | Description |
|---|---|---|
@load-more-top | - | Triggered when scrolling up reaches the threshold. Load history here. |
@load-more-bottom | - | Triggered when scrolling down reaches the threshold. Load more here. |
@scroll-state-change | (state: 'AT_BOTTOM' | 'SCROLLED_UP' | 'HAS_NEW_MESSAGES') | Triggered when scroll state changes. |
@unread-count-change | (count: number) | Triggered when unread count changes. |
Ref Instance Methods
| Method / Property | Signature | Description |
|---|---|---|
scrollToTop | (smooth?: boolean) => void | Scroll to the top. smooth controls animation (defaults to the smoothScroll prop). |
scrollToBottom | (smooth?: boolean) => void | Scroll to the bottom, resets unread count and state machine. |
scrollToBubble | (index: number, smooth?: boolean) => void | Scroll to the message at the specified index. |
loadMoreTopComplete | () => void | Call after top data finishes loading. The component auto-fixes scroll position. |
loadMoreBottomComplete | () => void | Call after bottom data finishes loading. |
currentScrollState | BubbleListScrollState | Current scroll state: AT_BOTTOM / SCROLLED_UP / HAS_NEW_MESSAGES. |
currentUnreadCount | number | Current unread message count. |
Slots
| Slot Name | Context Type | Description |
|---|---|---|
#avatar | BubbleListItemContext | Custom bubble avatar. |
#header | BubbleListItemContext | Custom bubble header area. |
#content | BubbleListItemContext | Custom bubble content area. |
#footer | BubbleListItemContext | Custom bubble footer area. |
#loading | BubbleListItemContext | Custom bubble loading state. |
#backToBottom | BubbleListBackButtonContext | Custom back-to-bottom button. Context includes unreadCount / scrollState / label / autoScroll / virtualEnabled / scrollToBottom(smooth?). |
#topStatus | BubbleListBoundaryContext | Custom top boundary status area. Context includes status / position / scrollState / unreadCount / autoScroll. |
#bottomStatus | BubbleListBoundaryContext | Custom bottom boundary status area, same as #topStatus. |
#item | BubbleListItemContext | Custom rendering for non-bubble type nodes, triggered when matched by itemType. |
title: BubbleList
📌 Warning
Added in version 1.1.6 Added scroll to bottom button, similar to Doubao🔥. Added scrollbar on mouse hover to enhance interaction experience. Please update and try it out.
🐵 This warm tip was last updated: 2025-04-13
💡 Tip
Note: The new version's auto-scroll will automatically scroll when the list length changes. However, after scrolling up, you need to manually call the scrollToBottom method to re-enable auto-scroll. Or, when the scrollbar reaches the bottom, auto-scroll will be triggered again.
The logic is the same as before, so you can upgrade without any worries.
Introduction
BubbleList relies on the Bubble component and is used to display a list of chat bubbles. This component supports setting the maximum list height and has an auto-scroll feature. It also provides various scroll control methods that users can easily call. It is powerful and requires no mental burden for developers.
Code Examples
Basic Usage
Customized List
Auto Scroll & Scroll to Specific Position
Back to Top Button
Theme Overrides (themeOverrides)
Override BubbleList theme tokens via ConfigProvider.themeOverrides. See the full token list and template:
Using with x-markdown-vue
Starting from v2.0.0, the component library no longer bundles XMarkdown / XMarkdownAsync. For Markdown rendering, use x-markdown-vue or see the dedicated page: XMarkdown.
Installation
pnpm add x-markdown-vue
pnpm add katex
pnpm add shiki shiki-stream2
3
💡 Tip
If you need code block syntax highlighting, please install shiki and shiki-stream. Otherwise, you may see this error in the console: Streaming highlighter initialization failed: Error: Failed to load shiki-stream module
Basic Usage
Fog Effect
Enable the typewriter fog animation via the enable-animate prop to simulate AI streaming output.
<script setup lang="ts">
import 'katex/dist/katex.min.css';
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const STREAM_TICK_MS = 50;
const STREAM_CHARS_PER_TICK = 6;
const list = ref([
{
key: '1',
content: 'Hi! Can you introduce the fog effect in BubbleList?',
placement: 'end' as const,
avatar: 'https://avatars.githubusercontent.com/u/1?s=40&v=4',
role: 'user'
},
{
key: '2',
content: '',
placement: 'start' as const,
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
role: 'ai'
}
]);
const isStreaming = ref(false);
let streamTimer: number | null = null;
let streamCharacters: string[] = [];
let streamOffset = 0;
const fullMarkdown = `### Fog Effect Introduction
The **Fog Effect** (animate) is a smooth transition animation provided by \`x-markdown-vue\`'s \`MarkdownRenderer\`:
- New text appears with a **fade-in** effect instead of being appended abruptly
- Combined with streaming output, it simulates an AI **incremental reply** visual experience
- Simply set \`enable-animate\` to enable it
#### Code Example
\`\`\`vue
<MarkdownRenderer
:markdown="content"
:enable-animate="true"
/>
\`\`\`
#### Formula Support
Inline formula $E = mc^2$, block formulas work as well:
$$
\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}
$$
> Tip: The fog effect works best in streaming scenarios and is even smoother when used with BubbleList auto-scroll.`;
function startStreaming() {
if (isStreaming.value) return;
list.value[1].content = '';
streamOffset = 0;
streamCharacters = Array.from(fullMarkdown);
const initialChunk = streamCharacters
.slice(0, STREAM_CHARS_PER_TICK)
.join('');
streamOffset = initialChunk.length;
list.value[1].content = initialChunk;
isStreaming.value = true;
streamTimer = window.setInterval(() => {
if (streamOffset >= streamCharacters.length) {
stopStreaming();
return;
}
const nextChunk = streamCharacters
.slice(streamOffset, streamOffset + STREAM_CHARS_PER_TICK)
.join('');
list.value[1].content += nextChunk;
streamOffset += nextChunk.length;
if (streamOffset >= streamCharacters.length) {
stopStreaming();
}
}, STREAM_TICK_MS);
}
function stopStreaming() {
if (streamTimer !== null) {
window.clearInterval(streamTimer);
streamTimer = null;
}
isStreaming.value = false;
}
function resetStreaming() {
stopStreaming();
list.value[1].content = '';
}
onMounted(() => {
startStreaming();
});
onUnmounted(() => {
stopStreaming();
});
</script>
<template>
<div class="fog-demo-container">
<div class="btn-list">
<el-button type="primary" :disabled="isStreaming" @click="startStreaming">
Start Streaming
</el-button>
<el-button :disabled="!isStreaming" @click="stopStreaming">
Stop
</el-button>
<el-button @click="resetStreaming"> Reset </el-button>
</div>
<div class="list-stage">
<BubbleList :list="list" max-height="420px">
<template #content="{ item }">
<div v-if="item.role === 'ai'" class="markdown-content-wrapper">
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="item.content || ''"
:enable-animate="true"
/>
<pre v-else>{{ item.content }}</pre>
</div>
<span v-else>{{ item.content }}</span>
</template>
</BubbleList>
</div>
</div>
</template>
<style scoped lang="scss">
.fog-demo-container {
display: flex;
flex-direction: column;
gap: 12px;
.btn-list {
display: flex;
gap: 12px;
}
.list-stage {
height: 420px;
padding: 8px 10px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
}
}
.markdown-content-wrapper {
word-break: break-word;
color: #24292e;
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin-top: 16px;
margin-bottom: 8px;
font-weight: 600;
line-height: 1.25;
&:first-child {
margin-top: 0;
}
}
:deep(p) {
margin-top: 0;
margin-bottom: 8px;
line-height: 1.6;
&:last-child {
margin-bottom: 0;
}
}
:deep(ul),
:deep(ol) {
padding-left: 20px;
margin-top: 0;
margin-bottom: 8px;
}
:deep(ul) {
list-style-type: disc;
}
:deep(li) {
margin: 4px 0;
line-height: 1.6;
}
:deep(code):not(pre code) {
background-color: rgba(27, 31, 35, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
monospace;
font-size: 85%;
}
:deep(blockquote) {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 8px 0;
}
}
:deep(.x-md-code-block) {
pre {
background-color: #f6f8fa !important;
padding: 16px;
border-radius: 8px;
overflow: auto;
code {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
monospace;
font-size: 14px;
line-height: 1.5;
.line {
display: block;
min-height: 1rem;
}
}
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
list | Array | Yes | None | Array containing bubble information. Each element is an object with content, placement, loading, shape, variant, and other Bubble properties to configure the display and style of each bubble. For Markdown, render via the #content slot. |
autoScroll | Boolean | No | true | Whether to enable automatic scrolling. |
maxHeight | String | No | '-' | Maximum height of the bubble list container.(By default, the height of the wrapper container is inherited) |
alwaysShowScrollbar | Boolean | No | false | Whether to always show the scrollbar. Default is false. |
backButtonThreshold | Number | No | 80 | Back to bottom button display threshold. When the scrollbar is more than this distance from the bottom, the button will be shown. |
showBackButton | Boolean | No | true | Whether to show the back to bottom button. Default is true. |
backButtonPosition | { bottom: '20px', left: 'calc(50% - 19px)' } | No | { bottom: '20px', left: 'calc(50% - 19px)' } | Position of the back to bottom button. Default is centered at the bottom. |
btnLoading | Boolean | No | true | Whether to enable loading state for the back to bottom button. Default is true. |
btnColor | String | No | '#409EFF' | Color of the back to bottom button. Default is '#409EFF'. |
btnIconSize | Number | No | 24 | Icon size of the back to bottom button. Default is 24px. |
Events
Ref Instance Methods
| Name | Type | Description |
|---|---|---|
scrollToTop | Function | Scroll to the top. |
scrollToBottom | Function | Scroll to the bottom. |
scrollToBubble | Function | Scroll to the specified bubble index. |
Slots
| Slot Name | Parameter | Type | Description |
|---|---|---|---|
#avatar | - | Slot | Custom avatar display content |
#header | - | Slot | Custom bubble header content |
#content | - | Slot | Custom bubble content |
#loading | - | Slot | Custom bubble loading state content |
#footer | - | Slot | Custom bubble footer content |
Features
- Smart Scrolling - Automatically tracks the latest message position
- Deep Customization - Full slot passthrough for bubble components
- Multiple Scrolling Methods - Scroll to top, bottom, or specific position
- Multiple Styles - Supports various styles such as round, square, etc.
