ChatGPT Scroll & Animation — Reverse Engineering Guide
Phân tích toàn bộ flow từ lúc click Send đến khi AI response hiển thị xong. Bao gồm: viewport + history management, scroll anchoring, placeholder, animations.
Mục lục
- Tổng quan kiến trúc
- Full Flow: Click Send → AI Done
- Viewport & History: minHeight Placeholder
- Scroll Anchor: Capture & Restore
- Scroll State Tracking
- Stream Active — Disable Anchor khi Streaming
- CSS Shadow Indicators
- Message Appearance Animations
- Scroll Scenarios chi tiết
- Full Working Example (React)
- Key Takeaways
1. Tổng quan kiến trúc
┌──────────────────────────────────────────────────┐
│ scroll-root (overflow-y: auto) │
│ ┌────────────────────────────────────────────┐ │
│ │ message-list (role="list") │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ [History messages] │ │ │
│ │ │ Message 1 (listitem) │ │ │
│ │ │ Message 2 (listitem) │ │ │
│ │ │ ... │ │ │
│ │ ├──────────────────────────────────────┤ │ │
│ │ │ [Live wrapper] ← minHeight=viewport │ │ │
│ │ │ 👤 User message (mới gửi) │ │ │
│ │ │ 🤖 AI response (đang stream) │ │ │
│ │ │ (khoảng trống fill dần) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ Shadow top / Shadow bottom │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ Composer (textarea + send button) │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
2. Full Flow: Click Send → AI Done
User click Send
│
├─ STEP 1: Clear composer
│ └─ setText('') — instant
│
├─ STEP 2: Tính placeholderHeight ← Section 3
│ └─ placeholderHeight = containerHeight - headerHeight - inputHeight
│
├─ STEP 3: Scroll anchor — capture TRƯỚC ← Section 4
│ └─ captureAnchor(container) → { anchor, anchorOffset, scrollTop, scrollHeight }
│
├─ STEP 4-7: View Transition wrap toàn bộ ← Section 8.1
│ └─ document.startViewTransition(() => {
│ // Browser chụp SCREENSHOT old state (messages cũ)
│
│ // STEP 4: Render user message + live wrapper
│ flushSync(() => {
│ addUserMessage(text)
│ setPlaceholderHeight(ph)
│ })
│ DOM lúc này:
│ [history messages]
│ [live wrapper: minHeight=viewportHeight] ← Section 3
│ └─ [user message]
│ └─ [AI placeholder: loading dots]
│
│ // STEP 5: Scroll anchor — restore ← Section 4
│ restoreScrollAfterUpdate()
│
│ // STEP 6: scrollIntoView ← Section 3
│ liveWrapper.scrollIntoView({ behavior: 'auto' })
│ → User message ở TOP viewport
│ → History bị đẩy lên TRÊN viewport (ẩn)
│
│ // Browser chụp SCREENSHOT new state
│ })
│
│ // STEP 7: Browser animate 2 screenshots đồng thời:
│ // OLD (messages cũ): cross-fade OUT 400ms ← Section 8.1
│ // NEW (user msg ở top): slide-UP 400ms ← Section 8.1
│
├─ STEP 8: Set stream active ← Section 6
│ └─ data-stream-active → overflow-anchor: none
│
├─ STEP 9: Stream chunks arrive (loop)
│ ├─ Append text vào AI message
│ ├─ Mỗi <p>, <pre>, <li> mới → CSS fade-in 700ms ← Section 8.2
│ ├─ AI response fill dần vào khoảng trống ← Section 3
│ └─ if (isAtBottom) scrollTop = scrollHeight ← Section 5
│
├─ STEP 10: Stream done
│ ├─ Remove data-stream-active ← Section 6
│ └─ placeholderHeight = 0 (live wrapper shrink về content height)
│
└─ DONE
3. Viewport & History: minHeight Placeholder
Đây là trick quan trọng nhất — cách ChatGPT đẩy message cũ ra khỏi viewport.
Nguyên lý
Khi send message mới, ChatGPT wrap user message + AI placeholder trong 1 div có minHeight ≥ viewport height. scrollIntoView() div đó → toàn bộ history bị đẩy lên trên, user message nằm ở top viewport.
Trước và sau send
TRƯỚC send: SAU send:
┌────────────────────┐ ┌────────────────────┐
│ Message 1 │ │ │ ← history ẩn phía trên
│ Message 2 │ │ │ (scroll lên để xem)
│ Message 3 │ ├────────────────────┤ ← viewport top
│ Message 4 │ │ 👤 "thị trường │ ← user message ở TOP
│ Message 5 │ │ hôm nay" │
│ │ │ │
│ │ │ 🤖 ●●● │ ← loading dots
├────────────────────┤ │ │
│ [input box] │ │ (khoảng trống) │ ← AI response fill dần
└────────────────────┘ │ │
├────────────────────┤ ← viewport bottom
│ [input box] │
└────────────────────┘
Tính placeholderHeight
function calcPlaceholderHeight(scrollRoot) {
const container = scrollRoot.getBoundingClientRect();
const headerHeight = getComputedStyle(scrollRoot)
.getPropertyValue('--header-height') || 48;
const inputHeight = document.querySelector('.chat-composer')
?.getBoundingClientRect().height || 60;
return container.height - headerHeight - inputHeight - 16;
}
placeholderHeight= chiều cao viewport trừ header và input. Đảm bảo live wrapper chiếm đủ 1 màn hình.
Render logic
function ChatStream({ messages, isStreaming, placeholderHeight }) {
const liveRef = useRef(null);
const lastUserIndex = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') return i;
}
return -1;
}, [messages]);
const hasPlaceholder = placeholderHeight > 0 && lastUserIndex >= 0;
return (
<div className="chat-message-list">
{hasPlaceholder ? (
<>
{/* PHẦN 1: History messages — render bình thường */}
{messages.slice(0, lastUserIndex).map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
{/* PHẦN 2: Live wrapper — chiếm ≥ 1 viewport */}
<div ref={liveRef} style={{ minHeight: placeholderHeight }}>
{messages.slice(lastUserIndex).map((msg, i) => (
<MessageBubble key={lastUserIndex + i} message={msg} isNew />
))}
{isStreaming && <LoadingDots />}
</div>
</>
) : (
<>
{messages.map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
{isStreaming && <LoadingDots />}
</>
)}
</div>
);
}
scrollIntoView trigger
useLayoutEffect(() => {
if (placeholderHeight > 0 && liveRef.current) {
requestAnimationFrame(() => {
liveRef.current.scrollIntoView({ behavior: 'auto' });
});
}
}, [placeholderHeight]);
behavior: 'auto'= instant scroll (không smooth). Vì View Transition API đã handle animation.
Khi nào active / reset
| Scenario | placeholderHeight | Behavior |
|---|---|---|
| Send message mới | > 0 | Placeholder active, scroll to live wrapper |
| F5 / load history | 0 | Không placeholder, render bình thường |
| Switch session | 0 | Reset, render history |
| AI stream done | Reset về 0 | Live wrapper shrink về content height |
Tại sao không hide/remove history?
History messages vẫn ở trong DOM — chỉ bị scroll lên trên viewport. User scroll lên là thấy ngay, không cần lazy load lại. minHeight trick đơn giản hơn và performant hơn hide/show.
4. Scroll Anchor: Capture & Restore
Nguyên lý
Khi DOM thay đổi (thêm/xóa message), browser có thể nhảy scroll. ChatGPT giải quyết bằng cách:
- Trước khi update DOM → Tìm message đầu tiên đang visible, ghi nhớ vị trí pixel của nó
- Update DOM → React
flushSyncđể đảm bảo DOM update xong ngay - Sau khi update DOM → Đo lại vị trí message đó, tính delta, set
scrollTop
Dùng ở STEP 3 + STEP 5 trong Full Flow.
captureAnchor() — Tìm element visible đầu tiên
function captureAnchor(container) {
const { scrollTop, scrollHeight } = container;
const rect = container.getBoundingClientRect();
const children = Array.from(container.children);
let anchor = null;
let anchorOffset = 0;
for (const child of children) {
if (!(child instanceof HTMLElement)) continue;
const childRect = child.getBoundingClientRect();
if (childRect.bottom > rect.top && childRect.top < rect.bottom) {
anchor = child;
anchorOffset = childRect.top - rect.top;
break;
}
}
return { anchor, anchorOffset, scrollTop, scrollHeight };
}
Giải thích:
- Duyệt qua children (messages) của scroll container
childRect.bottom > rect.top→ phần dưới của child nằm dưới top container (chưa bị scroll hết)childRect.top < rect.bottom→ phần trên của child nằm trên bottom container (đang visible)- Ghi nhớ
anchorOffset= khoảng cách từ top child đến top container (pixel)
restoreScrollAfterUpdate() — Giữ viewport ổn định
function restoreScrollAfterUpdate(container, updateFn) {
if (!container) {
updateFn();
return;
}
// BEFORE: Capture trước khi DOM thay đổi
const before = captureAnchor(container);
// UPDATE: React flushSync đảm bảo DOM update xong ngay
ReactDOM.flushSync(updateFn);
// AFTER: Đo lại sau khi DOM đã thay đổi
const after = captureAnchor(container);
// ADJUST: Nếu anchor bị dịch chuyển → set scrollTop
if (after.anchor !== before.anchor || Math.abs(after.anchorOffset - before.anchorOffset) > 0.5) {
let targetScrollTop;
if (before.anchor?.isConnected) {
// Anchor vẫn còn → tính vị trí mới
const containerRect = container.getBoundingClientRect();
const anchorRect = before.anchor.getBoundingClientRect();
targetScrollTop = anchorRect.top - containerRect.top + container.scrollTop - before.anchorOffset;
} else {
// Anchor đã bị xóa → fallback: dùng chênh lệch height
const heightDelta = container.scrollHeight - before.scrollHeight;
targetScrollTop = before.scrollTop + heightDelta;
}
// Clamp trong range hợp lệ
const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
const clampedScroll = Math.min(Math.max(0, targetScrollTop), maxScroll);
// Threshold 0.5px — tránh micro-jitter
if (Math.abs(container.scrollTop - clampedScroll) > 0.5) {
container.scrollTop = clampedScroll;
}
}
}
Khi nào cần anchor:
- Thêm message mới (STEP 4 trong flow)
- Load thêm message cũ (infinite scroll up)
- Xóa message
Khi nào KHÔNG cần:
- Streaming update text (chỉ update nội dung, không thêm/xóa element) → dùng auto-scroll ở Section 5
5. Scroll State Tracking
Theo dõi vị trí scroll qua data attributes trên container. CSS tự phản ứng — không cần re-render React.
Dùng ở STEP 9 trong Full Flow — auto-scroll khi stream.
function setupScrollTracking(container) {
if (!container) return () => {};
const update = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// Có messages ẩn phía trên?
toggleDataAttr(container, 'data-scrolled-from-top', scrollTop > 0);
// Có messages ẩn phía dưới? (threshold 10px)
const isScrolledFromEnd = scrollTop + clientHeight < scrollHeight - 10;
toggleDataAttr(container, 'data-scrolled-from-end', isScrolledFromEnd);
};
update();
container.addEventListener('scroll', update, { passive: true });
return () => container.removeEventListener('scroll', update);
}
function toggleDataAttr(el, attr, condition) {
if (condition) {
el.setAttribute(attr, '');
} else {
el.removeAttribute(attr);
}
}
Auto-scroll khi streaming (STEP 9)
function autoScrollOnStream(container, isAtBottom) {
if (isAtBottom && container) {
container.scrollTop = container.scrollHeight;
}
}
Chỉ auto-scroll nếu user đang ở bottom (
isAtBottom). Nếu user scroll lên đọc history → không auto-scroll → hiện nút "↓ scroll to bottom".
6. Stream Active — Disable Anchor khi Streaming
Dùng ở STEP 8 + STEP 10 trong Full Flow.
Khi AI đang stream, disable browser's built-in overflow-anchor để tự quản lý scroll:
function setStreamActive(container, active) {
toggleDataAttr(container, 'data-stream-active', active);
}
[data-stream-active] {
overflow-anchor: none;
}
Tại sao: Browser overflow-anchor: auto cố giữ element ở vị trí cũ khi content thay đổi. Khi streaming, ta muốn scroll follow content mới → phải tắt browser anchor.
7. CSS Shadow Indicators
Visual cue cho user biết có messages ẩn phía trên/dưới:
/* Top shadow — hiện khi scroll xuống khỏi top */
.scroll-shadow-top {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
pointer-events: none;
opacity: 0;
transition: opacity 300ms ease;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.06), transparent);
}
[data-scrolled-from-top] ~ .scroll-shadow-top {
opacity: 1;
}
/* Bottom indicator — hiện khi có messages ẩn phía dưới */
.scroll-shadow-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-top: 1px solid var(--border-color);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease;
}
[data-scrolled-from-end] ~ .scroll-shadow-bottom {
opacity: 0.35;
}
8. Message Appearance Animations
ChatGPT dùng 3 lớp animation khi message mới xuất hiện:
Dùng ở STEP 7 + STEP 9 trong Full Flow.
8.1 View Transition API — Old messages ra + New message vào (STEP 7)
ChatGPT dùng View Transition API để animate cả 2 chiều: old messages mờ dần ra VÀ new content trượt lên vào, như 1 transition liền mạch.
8.1.1 View Transition API là gì?
Browser API cho phép animate giữa 2 trạng thái DOM. Flow:
1. Browser chụp SCREENSHOT trạng thái CŨ (old messages trên viewport)
2. Callback chạy: DOM update (thêm message, set placeholder, scrollIntoView)
3. Browser chụp SCREENSHOT trạng thái MỚI (user message ở top viewport)
4. Browser tạo pseudo-elements overlay 2 screenshots lên nhau
5. Animate: old screenshot fade-out, new screenshot slide-up + fade-in
6. Xong → remove overlay, DOM thật hiện ra
Quan trọng: Trong suốt animation, user nhìn thấy 2 screenshots animate chứ không phải DOM thật. DOM thật đã update xong ở bước 2, chỉ bị ẩn sau overlay.
8.1.2 Pseudo-element tree browser tạo ra
Khi startViewTransition() chạy, browser tự tạo overlay:
::view-transition
└─ ::view-transition-group(page)
├─ ::view-transition-old(page) ← screenshot CŨ (messages cũ)
└─ ::view-transition-new(page) ← screenshot MỚI (user msg ở top)
└─ ::view-transition-group(composer)
├─ ::view-transition-old(composer) ← screenshot CŨ (input có text)
└─ ::view-transition-new(composer) ← screenshot MỚI (input empty)
Ta style các pseudo-element này bằng CSS để control animation.
8.1.3 Visual flow chi tiết
PHASE 1: Capture OLD state PHASE 2: DOM update (ẩn sau overlay)
┌──────────────────┐ ┌──────────────────┐
│ Msg 1 │ ← browser │ ẩn phía trên ↑ │
│ Msg 2 │ chụp ảnh ├──────────────────┤
│ Msg 3 │ vùng này │ 👤 New message │ ← DOM đã update
│ Msg 4 │ │ │ nhưng user
│ Msg 5 │ │ 🤖 ●●● │ chưa thấy
├──────────────────┤ │ (khoảng trống) │
│ [input: "hello"] │ ├──────────────────┤
└──────────────────┘ │ [input: empty] │
└──────────────────┘
↓ browser chụp ảnh mới
PHASE 3: Animate 2 screenshots (400ms)
┌──────────────────────────────────────────┐
│ │
│ ┌─ OLD screenshot ─┐ opacity: 1 → 0 │
│ │ Msg 1 │ (cross-fade out) │
│ │ Msg 2 │ 400ms │
│ │ Msg 3... │ │
│ └───────────────────┘ │
│ ↕ chồng lên nhau │
│ ┌─ NEW screenshot ─┐ translate: ↑ │
│ │ 👤 New message │ 20vw → 0 │
│ │ 🤖 ●●● │ opacity: 0 → 1 │
│ │ │ 400ms │
│ └───────────────────┘ │
│ │
└──────────────────────────────────────────┘
PHASE 4: Animation done → remove overlay → DOM thật hiện ra
┌──────────────────┐
│ 👤 New message │ ← DOM thật, không còn overlay
│ │
│ 🤖 ●●● │
│ (khoảng trống) │
├──────────────────┤
│ [input: empty] │
└──────────────────┘
8.1.4 CSS — Đầy đủ với comment
/* ============================================
SPRING EASING — dùng cho tất cả transitions
============================================ */
:root {
--spring-fast: linear(
0, .01942 1.83%, .07956 4.02%, .47488 13.851%,
.65981 19.572%, .79653 25.733%, .84834 29.083%,
.89048 32.693%, .9246 36.734%, .95081 41.254%,
.97012 46.425%, .98361 52.535%, .99665 68.277%, .99988
);
}
/* ============================================
ĐÁNH DẤU element nào tham gia View Transition
view-transition-name gán tên cho element.
Browser sẽ chụp screenshot riêng cho mỗi named element
và tạo pseudo-element tương ứng.
QUAN TRỌNG: Mỗi tên phải UNIQUE trên page tại thời điểm transition.
============================================ */
/* Message area — nơi messages hiển thị */
.chat-scroll-root {
view-transition-name: page;
}
/* Composer — input + send button */
.chat-composer {
view-transition-name: composer;
}
/* ============================================
OLD STATE — messages cũ biến mất
::view-transition-old(page) = screenshot của .chat-scroll-root
TRƯỚC khi DOM update.
Browser default: cross-fade (opacity 1 → 0).
Ta chỉ cần set duration + easing, không cần custom keyframes.
============================================ */
::view-transition-old(page) {
animation-duration: 0.4s;
animation-timing-function: var(--spring-fast);
}
/* ============================================
NEW STATE — content mới xuất hiện
::view-transition-new(page) = screenshot của .chat-scroll-root
SAU khi DOM update (user message ở top + placeholder).
Custom animation: slide lên từ 20vw phía dưới + fade in.
============================================ */
::view-transition-new(page) {
animation: slide-up 0.4s var(--spring-fast);
}
@keyframes slide-up {
0% {
opacity: 0;
translate: 0 20vw; /* bắt đầu ở 20vw dưới vị trí cuối */
}
to {
opacity: 1;
/* translate mặc định = 0 0, không cần khai báo */
}
}
/* ============================================
COMPOSER TRANSITION
Old composer (có text) → fade out 500ms
New composer (empty) → xuất hiện instant, không animation
============================================ */
::view-transition-old(composer) {
animation-duration: 0.5s;
animation-timing-function: var(--spring-fast);
}
::view-transition-new(composer) {
animation: none;
}
8.1.5 JavaScript — Step by step
import { flushSync } from 'react-dom';
/**
* Entry point khi user click Send.
* Wrap DOM update trong View Transition nếu browser hỗ trợ.
*/
function handleSend(text) {
// Clear composer text ngay lập tức
setText('');
// Check browser support
if (!document.startViewTransition) {
// Fallback: không animation, nhưng logic scroll/placeholder vẫn đúng
performDOMUpdate(text);
return;
}
// startViewTransition flow:
// 1. Browser PAUSE rendering
// 2. Chụp screenshot OLD state (messages hiện tại trên viewport)
// 3. Gọi callback (ta update DOM ở đây)
// 4. Chụp screenshot NEW state (user message ở top)
// 5. Tạo pseudo-element overlay
// 6. Animate old → new theo CSS rules
// 7. Xong → remove overlay, DOM thật hiện ra
const transition = document.startViewTransition(() => {
performDOMUpdate(text);
});
// Optional: handle transition done
transition.finished.then(() => {
console.log('View transition complete');
});
}
/**
* Update DOM đồng bộ:
* - Thêm user message
* - Set placeholder height
* - scrollIntoView live wrapper
*
* PHẢI dùng flushSync vì View Transition cần DOM update XONG
* trước khi nó chụp screenshot NEW state.
*/
function performDOMUpdate(text) {
// flushSync đảm bảo React commit DOM ngay lập tức,
// không defer hay batch — View Transition cần đo lường ngay
flushSync(() => {
// Thêm user message vào state
setMessages(prev => [...prev, { role: 'user', content: text }]);
// Tính và set placeholder height (Section 3)
// → live wrapper có minHeight ≥ viewport
const ph = calcPlaceholderHeight();
setPlaceholderHeight(ph);
});
// Sau flushSync, DOM đã có:
// [history messages]
// [live wrapper: minHeight = viewport]
// └─ [user message]
// └─ [AI placeholder]
// scrollIntoView đẩy live wrapper lên top viewport
// → history messages bị đẩy lên trên (ẩn)
// → user message nằm ở top viewport
// behavior: 'auto' = instant, không smooth
// (View Transition API đã handle animation,
// smooth scroll ở đây sẽ conflict)
liveRef.current?.scrollIntoView({ behavior: 'auto' });
}
/**
* Tính placeholder height = viewport - composer - spacing
* Đảm bảo live wrapper chiếm ≥ 1 màn hình
*/
function calcPlaceholderHeight() {
const scrollRoot = scrollRootRef.current;
if (!scrollRoot) return 0;
const viewportH = scrollRoot.getBoundingClientRect().height;
const composerH = document.querySelector('.chat-composer')
?.getBoundingClientRect().height || 60;
const spacing = 16;
return Math.max(0, viewportH - composerH - spacing);
}
8.1.6 React Hook — useViewTransitionSend
Hook đóng gói toàn bộ logic, dev chỉ cần gọi send(text):
import { useRef, useCallback } from 'react';
import { flushSync } from 'react-dom';
export function useViewTransitionSend({
scrollRootRef,
liveRef,
setMessages,
setPlaceholderHeight,
setText,
}) {
const send = useCallback((text) => {
setText('');
const doUpdate = () => {
flushSync(() => {
setMessages(prev => [...prev, { role: 'user', content: text }]);
const root = scrollRootRef.current;
if (root) {
const viewportH = root.getBoundingClientRect().height;
const composerH = document.querySelector('.chat-composer')
?.getBoundingClientRect().height || 60;
setPlaceholderHeight(Math.max(0, viewportH - composerH - 16));
}
});
liveRef.current?.scrollIntoView({ behavior: 'auto' });
};
if (document.startViewTransition) {
document.startViewTransition(doUpdate);
} else {
doUpdate();
}
}, [scrollRootRef, liveRef, setMessages, setPlaceholderHeight, setText]);
return send;
}
Sử dụng trong component:
function ChatContainer() {
const [messages, setMessages] = useState([]);
const [text, setText] = useState('');
const [placeholderHeight, setPlaceholderHeight] = useState(0);
const scrollRootRef = useRef(null);
const liveRef = useRef(null);
const send = useViewTransitionSend({
scrollRootRef,
liveRef,
setMessages,
setPlaceholderHeight,
setText,
});
return (
<div className="chat-wrapper">
{/* Scroll container — PHẢI có view-transition-name: page */}
<div ref={scrollRootRef} className="chat-scroll-root">
<div className="chat-message-list">
{/* ... render messages + live wrapper ... */}
</div>
</div>
{/* Composer — PHẢI có view-transition-name: composer */}
<div className="chat-composer">
<textarea value={text} onChange={e => setText(e.target.value)} />
<button onClick={() => send(text)}>Send</button>
</div>
</div>
);
}
8.1.7 Kết quả visual
| Phase | Thời gian | Old messages | New content | Composer |
|---|---|---|---|---|
| Start | 0ms | Visible (opacity 1) | Chưa render | Có text |
| Animate | 0-400ms | Cross-fade OUT (opacity 1→0) | Slide UP 20vw + fade IN | Old: fade out 500ms |
| Done | 400ms+ | Gone (overlay removed) | Visible (DOM thật) | Empty (instant) |
User nhìn thấy: Messages cũ mờ dần đồng thời message mới trượt lên mượt từ phía dưới. Composer text biến mất cùng lúc. Toàn bộ diễn ra trong 400ms, cảm giác như 1 page transition.
8.1.8 Browser Support & Fallback
// View Transition API support (2024+):
// Chrome 111+, Edge 111+, Opera 97+, Safari 18+
// KHÔNG hỗ trợ: Firefox (đang implement)
if (!document.startViewTransition) {
// Fallback: DOM update instant, không animation
// Logic scroll/placeholder vẫn hoạt động đúng
performDOMUpdate(text);
}
Fallback strategy: Tách biệt animation (View Transition) khỏi logic (scroll + placeholder). Khi không có View Transition:
- User message vẫn xuất hiện ở top viewport
- History vẫn bị đẩy lên
- Chỉ không có animation mượt giữa 2 trạng thái
8.1.9 Lưu ý khi implement
| Lưu ý | Chi tiết |
|---|---|
view-transition-name phải unique | Mỗi tên chỉ gán cho 1 element. Nếu 2 element cùng tên → crash |
flushSync bắt buộc | Không dùng → React batch update → screenshot NEW state chưa đúng |
behavior: 'auto' không phải 'smooth' | Smooth scroll sẽ conflict với View Transition animation |
| Không nest transition | startViewTransition trong startViewTransition → error |
| CSS phải load trước | ::view-transition-* rules phải có sẵn, không lazy load |
| Performance | Browser chụp screenshot = composite layer → nhanh, không re-layout |
8.1.10 Header trên Mobile — Sticky + View Transition riêng
Trên mobile, ChatGPT có header (logo, new chat, menu) nằm trên cùng. Header KHÔNG bị đẩy lên khi send message — nó đứng yên trong khi messages bên dưới transition.
Header HTML structure
<header
id="page-header"
data-fixed-header="less-than-xl"
class="
sticky top-0 z-20
h-header-height
flex items-center justify-between
p-2 touch:p-2.5
bg-token-main-surface-primary
pointer-events-none select-none
*:pointer-events-auto
[view-transition-name:var(--vt-page-header)]
[box-shadow:var(--sharp-edge-top-shadow-placeholder)]
group-data-scroll-from-top/scroll-root:[box-shadow:var(--sharp-edge-top-shadow)]
"
>
<!-- Logo + Model switcher -->
<!-- New chat button -->
<!-- Menu button -->
</header>
Key CSS tricks cho header
/* ============================================
1. STICKY — header dính trên cùng khi scroll
============================================ */
#page-header {
position: sticky;
top: 0;
z-index: 20; /* trên content (z-10) nhưng dưới modal */
height: var(--header-height);
background: var(--bg-main-surface-primary);
}
/* ============================================
2. POINTER EVENTS — chỉ con cái nhận click
Header bản thân transparent với mouse/touch,
chỉ buttons bên trong nhận event.
→ Content phía dưới header vẫn scrollable
nếu user touch vào vùng trống của header.
============================================ */
#page-header {
pointer-events: none;
}
#page-header * {
pointer-events: auto;
}
/* ============================================
3. SHADOW — chỉ hiện khi scroll xuống
Lúc đầu: không shadow (user ở top)
Khi scroll xuống: shadow xuất hiện → visual cue
có content ẩn phía trên.
============================================ */
#page-header {
box-shadow: var(--sharp-edge-top-shadow-placeholder); /* transparent */
}
/* Khi scroll-root có data-scroll-from-top → show shadow */
[data-scrolled-from-top] #page-header {
box-shadow: var(--sharp-edge-top-shadow);
}
/* ============================================
4. VIEW TRANSITION — header animate RIÊNG
Header có view-transition-name riêng → browser
chụp screenshot header tách biệt khỏi message area.
→ Header ĐỨNG YÊN trong khi messages transition.
============================================ */
#page-header {
view-transition-name: var(--vt-page-header);
}
/* Giữ header đứng yên — không animate */
::view-transition-old(page-header),
::view-transition-new(page-header) {
animation: none;
}
Scroll padding trừ header height
Khi scrollIntoView() scroll đến live wrapper, cần trừ header height để content không bị che:
.chat-scroll-root {
/* scroll-padding-top = header height
→ scrollIntoView sẽ dừng ở vị trí = target.top - headerHeight
→ content nằm ngay DƯỚI header, không bị che */
scroll-padding-top: var(--header-height);
}
SAU scrollIntoView (CÓ scroll-padding-top):
┌──────────────────┐
│ ≡ ChatGPT ✏️ │ ← header STICKY, đứng yên
├──────────────────┤ ← scroll dừng ở đây (trừ header)
│ 👤 New message │ ← user msg ngay dưới header
│ │
│ 🤖 ●●● │
│ (khoảng trống) │
├──────────────────┤
│ Ask anything │
└──────────────────┘
NẾU KHÔNG có scroll-padding-top:
┌──────────────────┐
│ ≡ ChatGPT ✏️ │ ← header CHE MẤT user message!
│ ░░░░░░░░░░░░░░░░│ ← user msg bị ẩn sau header
│ │
│ 🤖 ●●● │
│ (khoảng trống) │
├──────────────────┤
│ Ask anything │
└──────────────────┘
Safe area inset (notch iPhone)
.chat-scroll-root {
/* Tính safe area cho notch + header */
--scroll-root-safe-area-inset-top:
calc(var(--sticky-padding-top) + env(safe-area-inset-top, 0px));
/* Bottom: composer + keyboard + home indicator */
--scroll-root-safe-area-inset-bottom:
calc(
var(--sticky-padding-bottom)
+ var(--screen-keyboard-height, 0px)
+ env(safe-area-inset-bottom, 0px)
);
/* Tổng chiều cao an toàn để hiển thị content */
--scroll-root-safe-area-height:
calc(
100lvh
- var(--scroll-root-safe-area-inset-top)
- var(--scroll-root-safe-area-inset-bottom)
);
}
iPhone với notch:
┌──────────────────┐
│▓▓▓ notch ▓▓▓▓▓▓▓│ ← env(safe-area-inset-top)
├──────────────────┤
│ ≡ ChatGPT ✏️ │ ← header (--header-height)
├──────────────────┤
│ │ ← --scroll-root-safe-area-height
│ Messages here │ (content hiển thị an toàn)
│ │
├──────────────────┤
│ Ask anything │ ← composer
├──────────────────┤
│▓▓▓ home bar ▓▓▓▓│ ← env(safe-area-inset-bottom)
└──────────────────┘
placeholderHeight phải trừ header
Update calcPlaceholderHeight() — placeholder chỉ chiếm vùng dưới header:
function calcPlaceholderHeight() {
const scrollRoot = scrollRootRef.current;
if (!scrollRoot) return 0;
const viewportH = scrollRoot.getBoundingClientRect().height;
const headerH = parseFloat(
getComputedStyle(scrollRoot).getPropertyValue('--header-height')
) || 48;
const composerH = document.querySelector('.chat-composer')
?.getBoundingClientRect().height || 60;
const spacing = 16;
// Trừ header vì header sticky, không thuộc scroll content
return Math.max(0, viewportH - headerH - composerH - spacing);
}
Responsive: Header ẩn trên desktop lớn
/* Desktop xl+ → header transparent, không sticky */
@container main (min-width: 80rem) {
#page-header[data-fixed-header="less-than-xl"] {
background: transparent;
box-shadow: none !important;
--sticky-padding-top: 0px; /* → scroll-padding-top = 0 */
}
}
Tổng hợp — tất cả view-transition-name participants
Element view-transition-name Behavior khi send
─────────────────────────────────────────────────────────────────────────
Header --vt-page-header ĐỨNG YÊN (animation: none)
Message area (scroll-root) --vt-page OLD: fade-out, NEW: slide-up
Composer --vt-composer OLD: fade-out 500ms, NEW: instant
Model switcher --vt-thread-model-switcher ĐỨNG YÊN
Share button --vt_share_chat_wide_button ĐỨNG YÊN
Disclaimer --vt-disclaimer ĐỨNG YÊN
Key insight: Mỗi element có
view-transition-nameriêng → browser chụp screenshot tách biệt cho từng element → animate độc lập. Header đứng yên vìanimation: none, message area slide-up, composer fade-out. User cảm nhận chỉ messages thay đổi, header + chrome giữ nguyên.
8.2 Streaming Content Fade-In — Per-element staggered (STEP 9)
Từng element trong AI response fade in riêng khi được render:
.message-content p,
.message-content li,
.message-content tr,
.message-content hr,
.message-content blockquote,
.message-content code,
.message-content pre {
opacity: 0;
animation: content-fade-in var(--duration, 0.7s)
cubic-bezier(0.37, 0.55, 0.86, 0.88) forwards;
}
@keyframes content-fade-in {
to {
opacity: 1;
}
}
/* Inline code thừa hưởng opacity từ parent paragraph */
.message-content p code {
opacity: inherit;
animation: none;
}
Trick: Mỗi element bắt đầu opacity: 0. Khi stream render từng chunk vào DOM, element mới tự chạy animation → staggered tự nhiên không cần animation-delay.
8.3 Popover / Thread Enter — Scale + Fade
@keyframes popover-enter {
0% { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
@keyframes popover-exit {
0% { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.98); }
}
.thread-enter {
transform-origin: bottom;
animation: popover-enter 0.3s var(--spring-fast) both;
}
8.4 Full Animation Timeline
Time Event Animation Section
────────────────────────────────────────────────────────────────────────────
0ms Click Send
├─ Clear composer text instant
├─ Calc placeholderHeight — §3
├─ captureAnchor() — §4
└─ flushSync(render) — §4
~16ms DOM updated
├─ restoreScrollAfterUpdate() — §4
├─ scrollIntoView(liveWrapper) instant §3
└─ View Transition starts slide-up 400ms §8.1
~50ms AI placeholder visible
├─ Brand header fade-in 300ms
└─ Loading dots fade-in 300ms
~200ms+ Stream chunks arrive
├─ Paragraph 1 content-fade-in 700ms §8.2
├─ Paragraph 2 content-fade-in 700ms §8.2
├─ Code block content-fade-in 700ms §8.2
└─ Auto-scroll each chunk scrollTop = scrollH §5
Done Stream complete
├─ Remove data-stream-active — §6
└─ placeholderHeight = 0 wrapper shrink §3
9. Scroll Scenarios chi tiết
Scenario A: User gửi message (đang ở bottom) — 90% cases
TRƯỚC: SAU:
┌──────────────────┐ ┌──────────────────┐
│ Msg 3 │ │ ẩn phía trên ↑ │
│ Msg 4 │ ├──────────────────┤
│ Msg 5 │ │ 👤 New message │ ← user msg ở TOP
│ │ │ │
├──────────────────┤ │ 🤖 ●●● │ ← loading
│ [input] │ │ │ ← khoảng trống
└──────────────────┘ │ │ (AI fill dần)
├──────────────────┤
│ [input] │
└──────────────────┘
Flow: calcPlaceholderHeight() → flushSync(render) → scrollIntoView(liveWrapper) → message cũ bị đẩy lên. Dùng Section 3 (minHeight placeholder) + Section 4 (scroll anchor).
Scenario B: User đang đọc history (scroll lên giữa chừng)
TRƯỚC: SAU:
┌──────────────────┐ ┌──────────────────┐
│ Msg 5 │ ← anchor │ Msg 5 │ ← anchor GIỮ NGUYÊN
│ Msg 6 │ │ Msg 6 │
│ Msg 7 │ │ Msg 7 │
│ ... │ │ ... │
└──────────────────┘ └──────────────────┘
(new msg ở dưới, user KHÔNG thấy)
(hiện nút ↓ scroll to bottom)
Flow: captureAnchor() → ghi nhớ Msg 5 + offset → flushSync(render) → restoreScrollAfterUpdate() → scrollTop giữ nguyên → Msg 5 vẫn ở đúng pixel cũ. Không dùng placeholder vì user không ở bottom. Dùng Section 4 (scroll anchor) + Section 5 (hiện nút ↓).
Scenario C: Load thêm message cũ (infinite scroll up)
TRƯỚC: SAU:
┌──────────────────┐ ┌──────────────────┐
│ Msg 1 │ ← anchor │ Msg -9 (mới) │
│ Msg 2 │ │ ... │
│ Msg 3 │ │ Msg 0 (mới) │
│ │ │ Msg 1 │ ← anchor (đẩy xuống)
└──────────────────┘ └──────────────────┘
scrollTop: 0 scrollTop: +heightDelta
Flow: captureAnchor() → ghi Msg 1 → flushSync(prepend) → Msg 1 bị đẩy xuống → restoreScrollAfterUpdate() tính targetScroll = vị trí mới của Msg 1 - offset cũ → set scrollTop → user vẫn thấy Msg 1 ở đúng vị trí. Dùng Section 4 (scroll anchor).
Scenario D: AI stream xong → placeholder shrink
ĐANG stream: SAU stream done:
┌──────────────────┐ ┌──────────────────┐
│ 👤 "hôm nay" │ │ 👤 "hôm nay" │
│ │ │ │
│ 🤖 Thị trường │ │ 🤖 Thị trường │
│ hôm nay... │ │ hôm nay tăng │
│ │ │ mạnh do... │
│ (khoảng trống) │ ← minHeight │ │ ← minHeight = 0
│ │ ├──────────────────┤
├──────────────────┤ │ [input] │
│ [input] │ └──────────────────┘
└──────────────────┘
Flow: placeholderHeight = 0 → live wrapper shrink về content height → layout tự nhiên. Dùng Section 3 (reset placeholder).
10. Full Working Example (React)
useScrollAnchor.js — Section 4
import { useRef, useCallback } from 'react';
import { flushSync } from 'react-dom';
function captureAnchor(container) {
const { scrollTop, scrollHeight } = container;
const rect = container.getBoundingClientRect();
const children = Array.from(container.children);
let anchor = null;
let anchorOffset = 0;
for (const child of children) {
if (!(child instanceof HTMLElement)) continue;
const childRect = child.getBoundingClientRect();
if (childRect.bottom > rect.top && childRect.top < rect.bottom) {
anchor = child;
anchorOffset = childRect.top - rect.top;
break;
}
}
return { anchor, anchorOffset, scrollTop, scrollHeight };
}
export function useScrollAnchor() {
const containerRef = useRef(null);
const updateWithAnchor = useCallback((updateFn) => {
const container = containerRef.current;
if (!container) {
updateFn();
return;
}
const before = captureAnchor(container);
flushSync(updateFn);
const after = captureAnchor(container);
if (after.anchor !== before.anchor || Math.abs(after.anchorOffset - before.anchorOffset) > 0.5) {
let targetScroll;
if (before.anchor?.isConnected) {
const containerRect = container.getBoundingClientRect();
const anchorRect = before.anchor.getBoundingClientRect();
targetScroll = anchorRect.top - containerRect.top + container.scrollTop - before.anchorOffset;
} else {
targetScroll = before.scrollTop + (container.scrollHeight - before.scrollHeight);
}
const max = Math.max(0, container.scrollHeight - container.clientHeight);
const clamped = Math.min(Math.max(0, targetScroll), max);
if (Math.abs(container.scrollTop - clamped) > 0.5) {
container.scrollTop = clamped;
}
}
}, []);
return { containerRef, updateWithAnchor };
}
useScrollState.js — Section 5
import { useEffect, useState, useCallback } from 'react';
export function useScrollState(containerRef) {
const [scrolledFromTop, setScrolledFromTop] = useState(false);
const [scrolledFromEnd, setScrolledFromEnd] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(true);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
setScrolledFromTop(scrollTop > 0);
setScrolledFromEnd(scrollTop + clientHeight < scrollHeight - 10);
setIsAtBottom(scrollTop + clientHeight >= scrollHeight - 10);
};
onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, [containerRef]);
const scrollToBottom = useCallback((behavior = 'smooth') => {
const container = containerRef.current;
if (!container) return;
container.scrollTo({ top: container.scrollHeight, behavior });
}, [containerRef]);
return { scrolledFromTop, scrolledFromEnd, isAtBottom, scrollToBottom };
}
usePlaceholder.js — Section 3
import { useState, useCallback, useRef } from 'react';
export function usePlaceholder(scrollRootRef) {
const [placeholderHeight, setPlaceholderHeight] = useState(0);
const liveRef = useRef(null);
const activatePlaceholder = useCallback(() => {
const root = scrollRootRef.current;
if (!root) return;
const containerH = root.getBoundingClientRect().height;
const composerH = document.querySelector('.chat-composer')
?.getBoundingClientRect().height || 60;
const spacing = 16;
setPlaceholderHeight(containerH - composerH - spacing);
}, [scrollRootRef]);
const resetPlaceholder = useCallback(() => {
setPlaceholderHeight(0);
}, []);
return { placeholderHeight, liveRef, activatePlaceholder, resetPlaceholder };
}
ChatContainer.jsx — Full integration
import { useState, useLayoutEffect } from 'react';
import { useScrollAnchor } from './useScrollAnchor';
import { useScrollState } from './useScrollState';
import { usePlaceholder } from './usePlaceholder';
export function ChatContainer() {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const { containerRef, updateWithAnchor } = useScrollAnchor();
const { scrolledFromTop, scrolledFromEnd, isAtBottom, scrollToBottom } = useScrollState(containerRef);
const { placeholderHeight, liveRef, activatePlaceholder, resetPlaceholder } = usePlaceholder(containerRef);
// --- STEP 6: scrollIntoView khi placeholder active (Section 3) ---
useLayoutEffect(() => {
if (placeholderHeight > 0 && liveRef.current) {
requestAnimationFrame(() => {
liveRef.current.scrollIntoView({ behavior: 'auto' });
});
}
}, [placeholderHeight]);
// --- STEP 1→7: Send handler ---
const handleSend = (text) => {
// STEP 2: Tính placeholder (Section 3)
activatePlaceholder();
// STEP 3→5: Render với scroll anchor (Section 4)
updateWithAnchor(() => {
setMessages(prev => [...prev, { role: 'user', content: text }]);
});
streamResponse(text);
};
// --- STEP 8→10: Stream handler ---
const streamResponse = async (text) => {
// STEP 8 (Section 6)
setIsStreaming(true);
updateWithAnchor(() => {
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
});
// STEP 9: Stream chunks
const response = await fetchAIStream(text);
for await (const chunk of response) {
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: updated[updated.length - 1].content + chunk,
};
return updated;
});
// Auto-scroll (Section 5)
if (isAtBottom && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}
// STEP 10 (Section 6 + Section 3)
setIsStreaming(false);
resetPlaceholder();
};
// --- Tìm lastUserIndex cho placeholder split (Section 3) ---
const lastUserIndex = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') return i;
}
return -1;
})();
const hasPlaceholder = placeholderHeight > 0 && lastUserIndex >= 0;
return (
<div className="chat-wrapper">
{/* Scroll container */}
<div
ref={containerRef}
className="chat-scroll-root"
data-stream-active={isStreaming || undefined}
data-scrolled-from-top={scrolledFromTop || undefined}
data-scrolled-from-end={scrolledFromEnd || undefined}
>
<div role="list" className="chat-message-list">
{hasPlaceholder ? (
<>
{/* History messages — render bình thường */}
{messages.slice(0, lastUserIndex).map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
{/* Live wrapper — chiếm ≥ 1 viewport (Section 3) */}
<div ref={liveRef} style={{ minHeight: placeholderHeight }}>
{messages.slice(lastUserIndex).map((msg, i) => (
<MessageBubble key={lastUserIndex + i} message={msg} isNew />
))}
{isStreaming && <LoadingDots />}
</div>
</>
) : (
<>
{messages.map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
{isStreaming && <LoadingDots />}
</>
)}
</div>
</div>
{/* Shadow indicators (Section 7) */}
<div className="chat-shadow-top" />
{/* Scroll to bottom button (Section 5) */}
{scrolledFromEnd && (
<button className="chat-scroll-to-bottom" onClick={() => scrollToBottom()}>
↓
</button>
)}
<Composer onSend={handleSend} disabled={isStreaming} />
</div>
);
}
function MessageBubble({ message, isNew }) {
return (
<div
role="listitem"
className={`chat-message chat-message--${message.role} ${isNew ? 'chat-message--new' : ''}`}
>
<div className={`chat-message__content ${
message.role === 'assistant' ? 'streaming-content' : ''
}`}>
{message.content}
</div>
</div>
);
}
function Composer({ onSend, disabled }) {
const [text, setText] = useState('');
const handleSubmit = () => {
if (!text.trim() || disabled) return;
onSend(text.trim());
setText('');
};
return (
<div className="chat-composer">
<textarea
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder="Message..."
disabled={disabled}
rows={1}
/>
<button onClick={handleSubmit} disabled={disabled || !text.trim()} className="chat-composer__send">
Send
</button>
</div>
);
}
chat.css — Full styles
:root {
--spring-fast: linear(
0, .01942 1.83%, .07956 4.02%, .47488 13.851%,
.65981 19.572%, .79653 25.733%, .84834 29.083%,
.89048 32.693%, .9246 36.734%, .95081 41.254%,
.97012 46.425%, .98361 52.535%, .99665 68.277%, .99988
);
}
/* ============================================
LAYOUT
============================================ */
.chat-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
}
/* ============================================
SCROLL CONTAINER — Section 1
============================================ */
.chat-scroll-root {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
overscroll-behavior: contain;
}
/* Section 6: Disable browser anchor khi streaming */
.chat-scroll-root[data-stream-active] {
overflow-anchor: none;
}
/* ============================================
MESSAGE LIST — Section 3
============================================ */
.chat-message-list {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
min-height: 100%;
justify-content: flex-end;
}
.chat-message {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 1rem;
line-height: 1.5;
word-break: break-word;
}
.chat-message--user {
align-self: flex-end;
background: #2563eb;
color: white;
border-bottom-right-radius: 0.25rem;
}
.chat-message--assistant {
align-self: flex-start;
background: #f3f4f6;
color: #111;
border-bottom-left-radius: 0.25rem;
}
/* ============================================
MESSAGE ENTER ANIMATION — Section 8.1
============================================ */
.chat-message--new {
animation: message-enter 0.4s var(--spring-fast) both;
}
@keyframes message-enter {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================
STREAMING CONTENT FADE — Section 8.2
============================================ */
.streaming-content p,
.streaming-content li,
.streaming-content pre,
.streaming-content blockquote,
.streaming-content table tr,
.streaming-content hr {
opacity: 0;
animation: content-fade-in 0.7s cubic-bezier(0.37, 0.55, 0.86, 0.88) forwards;
}
@keyframes content-fade-in {
to {
opacity: 1;
}
}
.streaming-content p code {
opacity: inherit;
animation: none;
}
/* ============================================
SHADOW INDICATORS — Section 7
============================================ */
.chat-shadow-top {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
pointer-events: none;
opacity: 0;
transition: opacity 300ms ease;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.06), transparent);
}
.chat-scroll-root[data-scrolled-from-top] ~ .chat-shadow-top {
opacity: 1;
}
/* ============================================
SCROLL TO BOTTOM — Section 5
============================================ */
.chat-scroll-to-bottom {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid #e5e7eb;
background: white;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: background 150ms ease, color 150ms ease;
z-index: 10;
}
.chat-scroll-to-bottom:hover {
background: #f9fafb;
color: #111;
}
/* ============================================
COMPOSER
============================================ */
.chat-composer {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid #e5e7eb;
background: white;
}
.chat-composer textarea {
flex: 1;
resize: none;
border: 1px solid #d1d5db;
border-radius: 1rem;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
line-height: 1.5;
max-height: max(30vh, 5rem);
overflow-y: auto;
outline: none;
font-family: inherit;
}
.chat-composer textarea:focus {
border-color: #2563eb;
}
.chat-composer__send {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: #2563eb;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 150ms ease;
}
.chat-composer__send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
11. Key Takeaways
| Kỹ thuật | Section | Mục đích |
|---|---|---|
minHeight = viewport trên live wrapper | §3 | Đẩy history ra khỏi viewport khi send |
scrollIntoView(liveWrapper) | §3 | User message ở top, khoảng trống cho AI |
placeholderHeight = 0 khi done | §3 | Shrink wrapper về content height |
captureAnchor() + restoreScrollAfterUpdate() | §4 | Giữ viewport ổn định khi DOM thay đổi |
getBoundingClientRect() | §4 | Đo vị trí pixel chính xác |
flushSync() | §4 | DOM update xong trước khi đo lại |
| Threshold 0.5px | §4 | Tránh micro-jitter |
data-scrolled-from-top/end | §5 | Track scroll position, toggle UI |
isAtBottom → auto-scroll | §5 | Follow content khi streaming |
{ passive: true } | §5 | Scroll listener không block main thread |
overflow-anchor: none khi streaming | §6 | Tắt browser anchor, tự quản lý |
scrollbar-gutter: stable | §10 | Tránh layout shift khi scrollbar toggle |
overscroll-behavior: contain | §10 | Không scroll ra ngoài container |
justify-content: flex-end | §10 | Messages dính bottom khi ít |
| View Transition API slide-up | §8.1 | Page-level animation khi send |
--spring-fast linear() | §8.1 | Spring physics easing |
Per-element opacity: 0 + forwards | §8.2 | Stream content staggered fade |
data-* attributes + CSS selectors | §7 | Reactive UI không cần re-render |