Học từ Facebook #10: Thanh cuộn tuỳ biến chạy trên compositor
Facebook đặt thumb thanh cuộn vào không gian 3D để compositor tự di chuyển nó đúng tỉ lệ, kể cả khi main thread bận. Có chứng minh toán, code và demo.
Thanh cuộn mặc định trên Windows khá dày, luôn hiện, và chỉ đổi theo dark mode nếu trang khai báo color-scheme. Trên một sidebar hay một popup chat, nó chiếm chỗ và trông lạc tông. Gần như dự án nào cũng muốn thay nó bằng một thanh mảnh, bo tròn, chỉ hiện khi hover.
Thư viện phổ biến làm việc đó bằng JS: ẩn thanh cuộn gốc, vẽ một div làm thumb, rồi nghe sự kiện scroll để cập nhật vị trí:
scroller.addEventListener('scroll', () => {
thumb.style.transform = `translateY(${scroller.scrollTop * ratio}px)`;
});
ts
Code này sai ở một chỗ tinh vi: cuộn trang không chạy trên main thread, còn listener thì có. Khi main thread bận (render React, parse JSON, chạy quảng cáo), nội dung vẫn cuộn mượt nhưng thumb đứng yên hoặc nhảy cóc.
Facebook giải bài này trong BaseScrollableArea.react (thư mục BaseScrollableArea.react/ trong facebook-codebase) bằng một mẹo hình học: đặt thumb vào không gian 3D sao cho phép chiếu phối cảnh tự di chuyển nó đúng tỉ lệ. Không có listener scroll nào đụng tới vị trí thumb. JS chỉ chạy khi kích thước thay đổi (listener scroll duy nhất chỉ bật/tắt độ mờ của thumb).
Tip
Tóm tắt
- Thumb vẽ bằng JS bị trễ khi main thread bận, vì sự kiện
scrollchạy trên main thread còn việc cuộn thì không. - Facebook đặt thumb bên trong vùng cuộn, ở một chiều sâu Z với ma trận có
w = −1, sauperspective: 1px. Phép chiếu phối cảnh làm thumb đi xuống đúnghlần quãng cuộn, và việc này do compositor làm. - JS chỉ tính lại
thumb = b²/avà chuỗi transform khi kích thước đổi. - Hôm nay nên thử trước
scrollbar-width/scrollbar-color, rồi tới scroll-driven animations (animation-timeline: scroll()), cũng chạy trên compositor.
#Bên trong trình duyệt: vì sao thanh cuộn JS bị trễ
Chrome, Firefox và Safari đều có compositor thread riêng. Khi bạn lăn chuột trên một vùng cuộn (không có listener wheel/touchmove non-passive), compositor tự dịch chuyển layer của nội dung mà không cần hỏi main thread. Sự kiện scroll vẫn được gửi về main thread, nhưng bất đồng bộ, tối đa một lần mỗi frame, và chỉ khi main thread rảnh.
sequenceDiagram participant U as Người dùng participant C as Compositor thread participant M as Main thread (đang bận 180ms) U->>C: lăn chuột C->>C: dịch layer nội dung (frame 1) C->>C: dịch layer nội dung (frame 2) Note over C: thumb 3D nằm cùng layer tree nên cũng được dịch C-->>M: sự kiện scroll (xếp hàng) Note over M: còn bận, chưa xử lý C->>C: dịch layer nội dung (frame 3..10) M->>M: hết bận, chạy listener scroll M->>C: thumb JS nhảy tới vị trí mới (trễ ~180ms)
Muốn thumb bám theo nội dung ở mọi frame, vị trí của nó phải được tính bởi compositor. Compositor không chạy được JS, nhưng nó tính được transform 3D. Đó là cửa sổ mà Facebook tận dụng.
#Facebook làm thế nào?
Mình đã tra từng class atomic trong file CSS của repo. Phần tử cuộn có các style sau:
| Style | Giá trị thật | Mục đích |
|---|---|---|
hideScrollbar |
scrollbar-width: none, ::-webkit-scrollbar { display: none }, -ms-overflow-style: none |
ẩn thanh cuộn gốc |
perspective |
perspective: 1px; perspective-origin: right top; transform-style: preserve-3d; position: relative |
dựng không gian 3D |
Thumb là con trực tiếp của phần tử cuộn (không nằm trong wrapper nội dung):
position: absolute; top: 0; right: 0; width: 16px; padding: 0 4px; box-sizing: border-box; transform-origin: right top; pointer-events: noneopacity: 0; transition-duration: .3s. Khi hover, đang cuộn hoặc đang kéo thìopacity: 1; transition-duration: 0, nghĩa là hiện ngay lập tức và ẩn từ từ.- Bên trong là một div
background-color: var(--scroll-thumb), bo góc.
Kế bên là một track: position: absolute; top: 0; right: 0; width: 16px; background: var(--divider); opacity: 0, hover thì opacity: .3. Thumb không nhận chuột, nên mọi thao tác chuột đi vào track.
Hàm đo (chạy khi mount, khi window resize và khi ResizeObserver báo nội dung hoặc khung đổi kích thước, debounce 100ms):
e.style.display = "none"; // track
d.style.display = "none"; // thumb
var a = c.getBoundingClientRect(), b = a.height; // b = chiều cao khung
a = c.scrollHeight; // a = chiều cao nội dung
f = Math.pow(b, 2) / a; // thumb = b² / a
var h = (b - f) / (a - b);
d.style.height = a <= b ? "0px" : f + "px";
e.style.height = a + "px";
d.style.transform = ["matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,-1)",
"scale(" + 1 / h + ")", "translateZ(" + (1 - 1 / h) + "px)", "translateZ(-2px)"].join(" ");
js
Bốn dòng transform cuối là toàn bộ phép màu. Phần còn lại của component chỉ xử lý chuột:
- Bấm vào rãnh ngoài thumb:
setInterval16ms, mỗi lầnscrollTo(scrollTop ± 20), chỉ cuộn khi chuột còn nằm trên track (theo dõimouseenter/mouseleave). - Kéo thumb:
scrollTo(top0 + dy * scrollHeight / clientHeight). - Ẩn thumb: hiện trong 1 giây sau mỗi sự kiện
scroll, sau đó ẩn.
Và một danh sách nền tảng không dùng kỹ thuật này (hàm q() trong file): iOS, Android, Edge, IE và Firefox < 64, phát hiện bằng UserAgent. Trên các nền tảng đó, component render một div cuộn bình thường.
#Chứng minh: vì sao thumb đi đúng tỉ lệ
Đây là phần thú vị nhất. Ta cần chứng minh: khi nội dung cuộn S px, thumb đi xuống đúng h · S px, với
b= chiều cao khung,a= chiều cao nội dung,f = b² / a= chiều cao thumb (thumb chiếm phần khung đúng bằng tỉ lệ khung trên nội dung),h = (b − f) / (a − b)= quãng đường thumb đi được (b − f) chia quãng đường cuộn được (a − b).
#Bước 1: hệ toạ độ
Gốc toạ độ đặt ở góc trên bên phải của khung cuộn, đúng bằng perspective-origin: right top. Thumb có top: 0; right: 0 và transform-origin: right top, nên góc trên phải của thumb trùng gốc toạ độ khi chưa cuộn.
Thumb là phần tử absolute bên trong vùng cuộn, nên nó cuộn cùng nội dung: cuộn S px thì vị trí layout của nó dịch lên S, tức đang ở (0, −S) so với gốc.
Một điểm trên thumb có toạ độ cục bộ (x, y) với x ≤ 0 (thumb nằm bên trái gốc) và 0 ≤ y ≤ f. Viết dạng toạ độ thuần nhất (x, y, 0, 1).
#Bước 2: áp transform của thumb
Danh sách transform: A B C D nghĩa là ma trận A·B·C·D, áp vào điểm theo thứ tự từ phải sang trái:
translateZ(-2px)rồitranslateZ(1 − 1/h):z = −2 + 1 − 1/h = −1 − 1/h. Hai bước này tương đương mộttranslateZ(−1 − 1/h); việc tách đôi không ảnh hưởng kết quả.scale(1/h)(scale 2D, chỉ x và y):(x/h, y/h, −1 − 1/h, 1).matrix3d(…, 0,0,0,−1): ma trận đơn vị, chỉ có phần tử cuối là−1, nên đổi dấuw:(x/h, y/h, −1 − 1/h, −1).
#Bước 3: đặt vào vị trí và chiếu phối cảnh
Dịch chuyển do vị trí layout (0, −S) là một ma trận tịnh tiến. Với toạ độ thuần nhất, phép tịnh tiến cộng t · w chứ không cộng t:
Y = y/h + (−S) · w = y/h + (−S) · (−1) = y/h + S
text
Đây là chỗ w = −1 phát huy tác dụng: dấu của độ dịch do cuộn bị lật. Nội dung đi lên thì thumb đi xuống.
Phép chiếu với perspective: 1px (d = 1) biến w thành w − z/d:
w' = −1 − (−1 − 1/h) = 1/h
text
Cuối cùng trình duyệt chia cho w' để ra toạ độ màn hình:
X_màn_hình = (x/h) / (1/h) = x
Y_màn_hình = (y/h + S) / (1/h) = y + h · S
text
X = x: thumb không bị kéo giãn hay lệch ngang, rộng đúng như CSS.Y = y + h·S: mọi điểm của thumb dịch xuống đúngh · S, và chiều cao thumb vẫn làf.
Kiểm tra hai đầu: S = 0 thì thumb ở trên cùng. S = a − b (cuộn hết) thì thumb dịch h · (a − b) = b − f, nằm sát đáy khung. ∎
#Vì sao gốc phối cảnh phải ở góc phải?
Giả sử góc phải của thumb không trùng gốc phối cảnh mà lệch tx px theo chiều ngang (ví dụ thumb right: 20px). Phép tịnh tiến đó cũng bị nhân với w = −1 rồi nhân h khi chiếu:
X_màn_hình = (x/h + tx · (−1)) · h = x − h · tx
text
Thumb hiện ra ở −h·tx thay vì tx: bị lật sang phía đối diện và co lại theo hệ số h. Điều tương tự xảy ra với độ lệch theo chiều dọc. Mẹo chỉ đúng khi độ dịch duy nhất đi qua phép lật là độ dịch do cuộn. Vì vậy perspective-origin, transform-origin đều là right top và thumb nằm đúng top: 0; right: 0.
#Thử với số thật trong demo
Khung b = 298, nội dung a = 2060:
| Đại lượng | Giá trị |
|---|---|
thumb f = b²/a |
43.1px |
h = (298 − 43.1) / (2060 − 298) |
0.1447 |
scale(1/h) |
scale(6.91) |
tổng translateZ |
−7.91px |
cuộn S = 441 |
thumb ở 63.8px (đo thật trong Chrome: 63.7px) |
cuộn hết S = 1762 |
thumb ở 254.9px, đáy thumb ở 298px |
Mình đo các con số này bằng Playwright khi viết bài (script đo không nằm trong repo; đoạn tương tự ở phần Kiểm thử).
#Viết lại
Bản viết lại tách làm ba phần: hàm thuần computeThumb (dễ test), các style object, và attachPerspectiveScrollbar gắn hành vi vào 4 phần tử DOM. Không phụ thuộc React, nên dùng được ở bất kỳ đâu. Phần cốt lõi là computeThumb:
src/lib/perspectiveScrollbar.ts
/**
* Tính kích thước + transform của thumb.
* - Thumb chiếm tỉ lệ viewport/content của rãnh: thumb = b² / a (giống thanh cuộn hệ thống),
* nhưng không nhỏ hơn `minThumb` (Facebook không có giới hạn này).
* - ratio = quãng đường thumb đi được / quãng đường nội dung cuộn được.
* - Sau phép chiếu, toạ độ y của thumb trên màn hình = y + ratio * scrollTop (xem bài viết).
*/
export function computeThumb(viewport: number, content: number, minThumb = 0): ThumbMetrics {
if (content <= viewport || viewport <= 0) {
return { viewport, content, thumbHeight: 0, ratio: 0, transform: 'none', visible: false };
}
const thumbHeight = Math.min(viewport, Math.max(minThumb, (viewport * viewport) / content));
const ratio = (viewport - thumbHeight) / (content - viewport);
const transform = [
// w' = -w: lật dấu phần dịch chuyển do cuộn -> thumb đi ngược chiều nội dung
'matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,-1)',
// thu nhỏ trước để sau khi chia cho w' = 1/ratio thì kích thước trở lại như cũ
`scale(${1 / ratio})`,
// tổng z = -1 - 1/ratio -> w' = -1 - z = 1/ratio (với perspective 1px)
`translateZ(${1 - 1 / ratio}px)`,
'translateZ(-2px)',
].join(' ');
return { viewport, content, thumbHeight, ratio, transform, visible: true };
}
ts
Xem toàn bộ src/lib/perspectiveScrollbar.ts
src/lib/perspectiveScrollbar.ts
import type { CSSProperties } from 'react';
/**
* Thanh cuộn tuỳ biến mà thumb di chuyển hoàn toàn trên compositor: không có listener
* `scroll` nào cập nhật vị trí thumb (ý tưởng từ BaseScrollableArea của Facebook).
*
* Phần tử cuộn có `perspective: 1px`. Thumb là con `position: absolute` của nó (nên cuộn theo
* nội dung), nhưng được đặt ở một chiều sâu Z và một ma trận có w = -1 sao cho sau phép chiếu
* phối cảnh, nó đi XUỐNG với tốc độ `ratio` lần tốc độ cuộn. JS chỉ tính lại khi kích thước đổi.
*/
export interface ThumbMetrics {
/** chiều cao vùng nhìn thấy (b) */
viewport: number;
/** tổng chiều cao nội dung (a = scrollHeight) */
content: number;
thumbHeight: number;
/** thumb đi được bao nhiêu px khi nội dung cuộn 1px: (b - thumb) / (a - b) */
ratio: number;
transform: string;
visible: boolean;
}
/**
* Tính kích thước + transform của thumb.
* - Thumb chiếm tỉ lệ viewport/content của rãnh: thumb = b² / a (giống thanh cuộn hệ thống),
* nhưng không nhỏ hơn `minThumb` (Facebook không có giới hạn này).
* - ratio = quãng đường thumb đi được / quãng đường nội dung cuộn được.
* - Sau phép chiếu, toạ độ y của thumb trên màn hình = y + ratio * scrollTop (xem bài viết).
*/
export function computeThumb(viewport: number, content: number, minThumb = 0): ThumbMetrics {
if (content <= viewport || viewport <= 0) {
return { viewport, content, thumbHeight: 0, ratio: 0, transform: 'none', visible: false };
}
const thumbHeight = Math.min(viewport, Math.max(minThumb, (viewport * viewport) / content));
const ratio = (viewport - thumbHeight) / (content - viewport);
const transform = [
// w' = -w: lật dấu phần dịch chuyển do cuộn -> thumb đi ngược chiều nội dung
'matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,-1)',
// thu nhỏ trước để sau khi chia cho w' = 1/ratio thì kích thước trở lại như cũ
`scale(${1 / ratio})`,
// tổng z = -1 - 1/ratio -> w' = -1 - z = 1/ratio (với perspective 1px)
`translateZ(${1 - 1 / ratio}px)`,
'translateZ(-2px)',
].join(' ');
return { viewport, content, thumbHeight, ratio, transform, visible: true };
}
/** Style cho phần tử cuộn. Cần thêm CSS `::-webkit-scrollbar { display: none }` để ẩn thanh cuộn gốc. */
export const perspectiveScrollerStyle: CSSProperties = {
position: 'relative',
overflowY: 'auto',
overscrollBehaviorY: 'contain',
perspective: '1px',
perspectiveOrigin: 'right top',
transformStyle: 'preserve-3d',
scrollbarWidth: 'none',
};
/** Thumb: con trực tiếp của phần tử cuộn. Không nhận chuột, chuột đi vào track bên dưới. */
export const perspectiveThumbStyle: CSSProperties = {
position: 'absolute',
top: 0,
right: 0,
width: 12,
padding: '0 3px',
boxSizing: 'border-box',
transformOrigin: 'right top',
pointerEvents: 'none',
opacity: 0,
display: 'none',
};
/** Track: cao bằng toàn bộ nội dung nên luôn phủ dải bên phải khi cuộn. Nhận chuột. */
export const perspectiveTrackStyle: CSSProperties = {
position: 'absolute',
top: 0,
right: 0,
width: 12,
display: 'none',
};
export interface PerspectiveScrollbarElements {
scroller: HTMLElement;
/** bọc nội dung, để ResizeObserver biết khi nội dung đổi chiều cao */
content: HTMLElement;
thumb: HTMLElement;
track: HTMLElement;
}
export interface PerspectiveScrollbarOptions {
minThumb?: number;
/** ẩn thumb sau bao lâu không cuộn */
hideAfterMs?: number;
onMetrics?: (metrics: ThumbMetrics) => void;
}
/**
* Gắn hành vi vào 4 phần tử. Trả về hàm huỷ. Dùng được với bất kỳ framework nào.
* Thứ duy nhất chạy theo `scroll` là bật/tắt độ mờ của thumb, không đụng tới vị trí.
*/
export function attachPerspectiveScrollbar(
{ scroller, content, thumb, track }: PerspectiveScrollbarElements,
{ minThumb = 24, hideAfterMs = 1000, onMetrics }: PerspectiveScrollbarOptions = {},
): () => void {
let metrics = computeThumb(0, 0);
let hovered = false;
let dragging = false;
let scrolling = false;
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
const render = () => {
const show = metrics.visible && (hovered || dragging || scrolling);
thumb.style.opacity = show ? '1' : '0';
// hiện ngay lập tức, ẩn thì mờ dần (Facebook: 0s khi hiện, .3s khi ẩn)
thumb.style.transition = show ? 'opacity 0s' : 'opacity .3s';
};
const measure = () => {
// Ẩn thumb + track TRƯỚC khi đo: track cao bằng scrollHeight cũ, nếu để nguyên thì nội dung
// co lại mà scrollHeight vẫn giữ giá trị cũ (track tự "chống" chiều cao của chính nó).
thumb.style.display = 'none';
track.style.display = 'none';
metrics = computeThumb(scroller.clientHeight, scroller.scrollHeight, minThumb);
thumb.style.height = `${metrics.thumbHeight}px`;
thumb.style.transform = metrics.transform;
track.style.height = `${metrics.content}px`;
thumb.style.display = metrics.visible ? 'block' : 'none';
track.style.display = metrics.visible ? 'block' : 'none';
render();
onMetrics?.(metrics);
};
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleMeasure = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(measure, 100); // debounce 100ms như Facebook
};
const onScroll = () => {
scrolling = true;
render();
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
scrolling = false;
render();
}, hideAfterMs);
};
const onEnter = () => {
hovered = true;
render();
};
const onLeave = () => {
hovered = false;
render();
};
// Kéo thumb / giữ chuột trên rãnh
let repeatTimer: ReturnType<typeof setInterval> | undefined;
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0 || !metrics.visible) return;
event.preventDefault();
const scrollerTop = scroller.getBoundingClientRect().top;
const startY = event.clientY;
const startScroll = scroller.scrollTop;
const thumbTop = scrollerTop + startScroll * metrics.ratio;
let pointerY = startY;
track.setPointerCapture(event.pointerId);
if (startY < thumbTop || startY > thumbTop + metrics.thumbHeight) {
// Bấm vào rãnh ngoài thumb: cuộn dần 20px mỗi 16ms về phía con trỏ, và DỪNG khi thumb đã
// chạm tới con trỏ (giống thanh cuộn hệ thống), không cuộn tuốt tới cuối.
const step = startY < thumbTop ? -20 : 20;
repeatTimer = setInterval(() => {
const top = scroller.getBoundingClientRect().top + scroller.scrollTop * metrics.ratio;
const reached = step < 0 ? pointerY >= top : pointerY <= top + metrics.thumbHeight;
if (reached) clearInterval(repeatTimer);
else scroller.scrollBy({ top: step });
}, 16);
} else {
dragging = true;
render();
}
const onMove = (e: PointerEvent) => {
pointerY = e.clientY;
if (!dragging) return;
// thumb đi 1px thì nội dung đi 1/ratio px
scroller.scrollTop = startScroll + (e.clientY - startY) / metrics.ratio;
};
const onUp = () => {
clearInterval(repeatTimer);
dragging = false;
render();
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onUp);
};
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onUp);
};
const observer = new ResizeObserver(scheduleMeasure);
observer.observe(scroller);
observer.observe(content);
window.addEventListener('resize', scheduleMeasure);
scroller.addEventListener('scroll', onScroll, { passive: true });
scroller.addEventListener('pointerenter', onEnter);
scroller.addEventListener('pointerleave', onLeave);
track.addEventListener('pointerdown', onPointerDown);
measure();
return () => {
observer.disconnect();
clearTimeout(resizeTimer);
clearTimeout(scrollTimer);
clearInterval(repeatTimer);
window.removeEventListener('resize', scheduleMeasure);
scroller.removeEventListener('scroll', onScroll);
scroller.removeEventListener('pointerenter', onEnter);
scroller.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onPointerDown);
};
}
/**
* Facebook tắt kỹ thuật này (dùng thanh cuộn gốc) trên iOS, Android, Edge (EdgeHTML), IE và
* Firefox < 64 bằng UA sniffing. Bản viết lại dùng feature query: trình duyệt phải hỗ trợ
* perspective + preserve-3d, và thiết bị có con trỏ chính là chuột/trackpad (màn hình cảm ứng
* đã có thanh cuộn overlay mỏng sẵn, không cần thay).
*/
export function shouldUsePerspectiveScrollbar(): boolean {
if (typeof window === 'undefined' || typeof CSS === 'undefined') return false;
if (!CSS.supports('perspective', '1px') || !CSS.supports('transform-style', 'preserve-3d')) return false;
return !window.matchMedia('(pointer: coarse)').matches;
}
ts
Khác bản gốc: kéo thumb dùng Pointer Events + setPointerCapture thay cho mousemove/mouseup trên window; bấm vào rãnh thì cuộn tới khi thumb chạm con trỏ rồi dừng (Facebook cuộn mãi trong lúc chuột còn trên rãnh); thumb có kích thước tối thiểu minThumb; chọn nền tảng bằng feature query thay vì dò User-Agent.
#Đi qua code
computeThumb là bốn dòng của Facebook, thêm hai cải tiến nhỏ:
minThumb: nội dung rất dài thìb²/acó thể chỉ còn 2–3px, gần như không bấm được. Công thức chứng minh ở trên đúng với mọi chiều cao thumbf, chỉ cần tínhhtừ chínhfđó. Vì vậy kẹpf ≥ 24pxvẫn chính xác.- Trả về
visible: falsekhi nội dung không dài hơn khung, thay vì chia cho 0.
perspectiveScrollerStyle ngoài bộ ba perspective/perspective-origin/transform-style còn có overscroll-behavior-y: contain: cuộn tới cuối danh sách thì trang phía sau không bị cuộn theo (scroll chaining), hợp với sidebar và popup chat.
measure ẩn thumb và track trước khi đo, đúng như Facebook. Lý do: track cao bằng scrollHeight cũ. Nếu nội dung co lại mà track vẫn hiện, track sẽ "chống" vùng cuộn giữ nguyên chiều cao cũ, và scrollHeight không bao giờ giảm được. Ẩn đi, đo, rồi hiện lại, tất cả trong cùng một task nên không có frame nào bị vẽ ở trạng thái ẩn.
render chỉ đổi opacity và transition. Đây là việc duy nhất chạy theo scroll, và nếu nó trễ thì chỉ là thumb hiện muộn một chút, không lệch vị trí.
Kéo thumb: thumb đi 1px thì nội dung đi 1/h px. Nhờ setPointerCapture, kéo ra ngoài cửa sổ vẫn nhận được sự kiện, và chạy được với bút cảm ứng.
Bấm vào rãnh: mỗi 16ms cuộn 20px về phía con trỏ, và tính lại vị trí thumb (top + scrollTop · h) để dừng khi thumb đã chạm con trỏ, giống thanh cuộn hệ thống.
shouldUsePerspectiveScrollbar: Facebook dò User-Agent. Bản viết lại dùng feature query: không hỗ trợ perspective/preserve-3d thì thôi, và thiết bị cảm ứng chính (pointer: coarse) thì cũng thôi. Thanh cuộn overlay của iOS và Android vốn đã mảnh và tự ẩn, không cần thay.
#Dùng trong dự án
function ScrollArea({ children }: { children: ReactNode }) {
const scroller = useRef<HTMLDivElement>(null);
const content = useRef<HTMLDivElement>(null);
const thumb = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shouldUsePerspectiveScrollbar()) return;
return attachPerspectiveScrollbar({
scroller: scroller.current!, content: content.current!,
thumb: thumb.current!, track: track.current!,
});
}, []);
return (
// tabIndex: vùng cuộn không có phần tử focus được bên trong thì bàn phím vẫn cuộn được
<div ref={scroller} className='scroll-area' style={perspectiveScrollerStyle} tabIndex={0}>
<div ref={content}>{children}</div>
{/* chỉ để nhìn và kéo bằng chuột: trình đọc màn hình bỏ qua */}
<div ref={track} style={perspectiveTrackStyle} aria-hidden />
<div ref={thumb} style={perspectiveThumbStyle} aria-hidden><div className='thumb' /></div>
</div>
);
}
tsx
.scroll-area::-webkit-scrollbar { display: none; }
.thumb { height: 100%; border-radius: 6px; background: rgb(0 0 0 / .4); }
css
#Demo
Bật "Chặn main thread" (cứ 200ms thì bận liên tục 180ms), rồi lăn chuột hoặc touchpad trên cả hai khung:
- Khung trái (perspective): thumb xám luôn bám theo nội dung.
- Khung phải (JS): thumb đỏ đứng yên rồi nhảy cóc.
Bấm "Thêm 20 dòng" để thấy ResizeObserver tính lại kích thước thumb. Các con số b, a, thumb, ratio và chuỗi transform thật được in ra bên dưới.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
Note
Khác biệt rõ nhất khi cuộn bằng chuột hoặc touchpad thật. Cuộn bằng cách kéo thumb hoặc bấm phím thì chính việc cuộn đó cũng phải chờ main thread, nên cả hai khung đều đứng.
#Tại sao không làm cách khác?
"position: sticky cho thumb?" Sticky giữ thumb đứng yên tại một chỗ, nhưng không làm nó di chuyển theo tỉ lệ khác 1:1 hay 0:1. Thumb cần đi chậm hơn nội dung h lần.
"Dùng requestAnimationFrame thay cho listener scroll?" rAF cũng chạy trên main thread. Main thread bận thì rAF cũng không được gọi.
"Sao không dùng scroll-driven animations?" Đó đúng là lời giải hiện đại, xem phần cuối bài. Nhưng code này có từ thời Comet (bản facebook.com viết lại, ra mắt năm 2020), khi tính năng đó chưa tồn tại. Ngay cả hôm nay, animation-timeline vẫn chưa có ở mọi trình duyệt.
"Sao không giữ thanh cuộn gốc và chỉ đổi màu?" scrollbar-color và scrollbar-width (chuẩn CSS) đã có ở Chrome 121+ và Firefox; với Safari hãy kiểm tra caniuse (tính tới 10/2026). Nhưng chúng chỉ đổi được màu và độ dày; không làm được "ẩn khi không hover, hiện ngay khi cuộn". Với nhiều sản phẩm như vậy là đủ, và đó nên là lựa chọn đầu tiên của bạn.
#Edge case và cạm bẫy
perspectivetạo containing block mới. Mọi phần tửposition: fixedbên trong vùng cuộn sẽ fixed theo vùng cuộn, không theo viewport nữa. Cùng vớitransform-style: preserve-3d, nó cũng tạo stacking context mới. Popover hay tooltip dùngfixedmà render bên trong vùng cuộn sẽ bị kẹt. Hãy render chúng qua portal rabody(Facebook làm vậy với mọi layer).- Nội dung trong không gian 3D.
preserve-3dcho phép các con có transform 3D tương tác với nhau. Nội dung bình thường (z = 0) không bị ảnh hưởng. Nhưng nếu nội dung bản thân có transform 3D (card lật, parallax) thì có thể đi vào không gian của thumb. Hiếm gặp, nhưng là lý do nên tách vùng cuộn và nội dung 3D. - Track phải cao bằng nội dung. Track là
absolute, cuộn theo nội dung. Nó cao bằngscrollHeightthì ở vị trí cuộn nào cũng phủ kín dải bên phải. Cao thiếu thì cuộn xuống dưới sẽ mất chỗ bấm. - Thumb
pointer-events: none. Hit-test trên phần tử có transform 3D phức tạp, và vị trí layout của thumb (luôn ởtop: 0của nội dung) khác với vị trí nhìn thấy. Facebook chuyển mọi thao tác chuột sang track phẳng rồi tự tính thumb đang nằm đâu (scrollTop · h). - RTL. Thanh cuộn phải nằm bên trái: đổi
perspective-origin,transform-originthànhleft top, đổiright: 0thànhleft: 0. Chứng minh ở trên vẫn đúng nguyên vẹn. - Cuộn ngang. Cùng ý tưởng nhưng đổi vai trò x và y. Facebook không làm (nhánh
horizontaldùng thanh cuộn gốc). - Accessibility. Thanh cuộn gốc bị ẩn nhưng vùng cuộn vẫn là vùng cuộn: phím mũi tên, Page Down và trình đọc màn hình vẫn hoạt động. Thumb và track tự vẽ chỉ để nhìn, nên đặt
aria-hiddenvà không đặttabIndexcho chúng. Ngược lại, chính vùng cuộn cầntabIndex={0}nếu bên trong không có phần tử nào focus được, nếu không người dùng bàn phím không cuộn được nó. - Khoảng trễ 100ms khi kích thước đổi.
measuređược debounce 100ms. Trong khoảng đó (nội dung vừa dài ra, ảnh vừa tải xong),hvẫn là giá trị cũ: thumb có thể sai kích thước, thậm chí trôi quá đáy khung một chút, cho tới lần đo kế tiếp. Muốn chặt hơn thì đo ngay trong callback củaResizeObserver(nó chạy trước khi vẽ) thay vì debounce, đổi lại tốn thêm vài lần đo khi nội dung đổi liên tục.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
Phần toán là hàm thuần, test rất dễ:
import { computeThumb } from './perspectiveScrollbar';
test('thumb chiếm đúng tỉ lệ và đi hết rãnh', () => {
const m = computeThumb(298, 2060);
expect(m.thumbHeight).toBeCloseTo(43.11, 1);
// cuộn hết (a - b) thì thumb đi đúng (b - thumb)
expect(m.ratio * (2060 - 298)).toBeCloseTo(298 - m.thumbHeight, 5);
});
test('minThumb vẫn giữ đúng hai đầu', () => {
const m = computeThumb(300, 100_000, 24);
expect(m.thumbHeight).toBe(24);
expect(m.ratio * (100_000 - 300)).toBeCloseTo(300 - 24, 5);
});
ts
Còn phép chiếu thì phải để trình duyệt thật tính. Playwright đo getBoundingClientRect() của thumb ở nhiều vị trí cuộn:
for (const frac of [0, 0.25, 0.5, 1]) {
await area.evaluate((el, f) => (el.scrollTop = (el.scrollHeight - el.clientHeight) * f), frac);
const { top, scrollTop, ratio } = await page.evaluate(/* đọc rect + metrics */);
expect(top).toBeCloseTo(scrollTop * ratio, 0);
}
ts
#Lựa chọn hiện đại
1. Chỉ đổi màu và độ dày: chuẩn CSS, ngắn nhất.
.scroll-area { scrollbar-width: thin; scrollbar-color: rgb(0 0 0 / .4) transparent; }
css
2. ::-webkit-scrollbar: tuỳ biến nhiều hơn trên Chromium và Safari. Chrome 121+ ưu tiên scrollbar-color/scrollbar-width nếu cả hai cùng được khai báo.
3. Scroll-driven animations: cũng chạy trên compositor như mẹo của Facebook, nhưng rõ ràng hơn nhiều:
/* thumb nằm ngoài vùng cuộn nên cần timeline-scope ở phần tử cha chung */
.wrap { position: relative; timeline-scope: --list; }
.scroll-area { height: 300px; overflow-y: auto; scrollbar-width: none; scroll-timeline: --list y; }
.thumb {
position: absolute; top: 0; right: 2px; width: 6px; height: 40px;
border-radius: 3px; background: rgb(0 0 0 / .4);
animation: move-thumb linear both;
animation-timeline: --list; /* tiến độ animation = tiến độ cuộn */
}
@keyframes move-thumb { to { translate: 0 calc(300px - 40px); } }
css
Ví dụ này để thumb cố định 40px cho gọn. Muốn thumb tỉ lệ với nội dung thì vẫn cần JS tính b²/a khi kích thước đổi, giống hệt Facebook.
Chromium hỗ trợ animation-timeline từ bản 115 và Safari từ bản 26; với Firefox hãy kiểm tra caniuse (tính tới 10/2026) trước khi bỏ hẳn phương án dự phòng.
Mẹo perspective của Facebook vẫn đáng học vì một lý do khác: nó là bài tập rất hay về ma trận 3D trong CSS. Hiểu nó rồi thì những thứ như parallax thuần CSS (perspective + translateZ + scale) cũng trở nên hiển nhiên.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D (bài này)
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

