Custom themed scrollbars for macOS WKWebView apps...
WKWebView on macOS does not support standard CSS scrollbar styling:
::-webkit-scrollbar pseudo-elements are ignoredscrollbar-color and scrollbar-width CSS properties don't work reliablyThis means CSS-based scrollbar theming that works in browsers will NOT work in the native macOS app.
Hide the native scrollbar using pure CSS layout (not pseudo-elements):
overflow: hidden clips the native scrollbaroverflow-y: scroll + marginRight: -20px pushes scrollbar outsidepaddingRight: 20px ensures content isn't cut offUse the OverlayScrollbar component from @/components/OverlayScrollbar:
import { OverlayScrollbar } from "@/components/OverlayScrollbar";
// Basic usage
<OverlayScrollbar className="h-full">
<div>Your scrollable content here</div>
</OverlayScrollbar>
// With scroll position persistence
const scrollRef = useTabScrollPersistence(tabId);
<OverlayScrollbar
scrollRef={scrollRef}
className="flex-1 h-full"
style={{ backgroundColor: currentTheme.styles.surfacePrimary }}
>
<div>Content with scroll position saved</div>
</OverlayScrollbar>
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Scrollable content |
className |
string |
CSS classes for outer wrapper |
style |
CSSProperties |
Inline styles for outer wrapper |
scrollRef |
RefObject<HTMLDivElement> |
Optional ref for scroll position access |
currentTheme.styles.borderDefault for scrollbar colorUse OverlayScrollbar instead of native overflow-y-auto when:
See the full component at: src/components/OverlayScrollbar.tsx
Key constants:
SCROLLBAR_WIDTH = 20 - Margin to hide native scrollbar (macOS scrollbar is ~15-17px)When implementing scroll persistence for workspace tabs, use useTabScrollPersistence with OverlayScrollbar.
Radix UI's TabsContent (used by shadcn Tabs) unmounts content when the tab is not active (unless forceMount is set). This means:
isActive state transitions to detect tab switches — the component always mounts fresh with isActive=trueThis is why scroll position must be saved to a module-level Map (survives unmounts) rather than component state or refs.
useTabScrollPersistence(tabId) returns a ref and manages two concerns:
Saving (scroll listener):
scrollTop to a module-level Map<string, number>scrollTop=0 from the fresh mountRestoring (settling window):
const scrollRef = useTabScrollPersistence(tabId);
<OverlayScrollbar scrollRef={scrollRef} className="flex-1">
{/* content */}
</OverlayScrollbar>
Do NOT pass an isActive parameter. The hook only takes tabId. Since Radix unmounts inactive tabs, isActive transition detection is impossible (dead code).
The ref must be attached to a mounted element when useTabScrollPersistence's effect runs.
If you conditionally render a different tree during loading, the ref won't be set and restoration will fail:
// BAD - OverlayScrollbar unmounts during loading, ref is null when effect runs
if (isLoading) {
return <Loader />; // Different tree, no OverlayScrollbar!
}
return (
<OverlayScrollbar scrollRef={scrollRef}>
{/* content */}
</OverlayScrollbar>
);
// GOOD - OverlayScrollbar stays mounted, ref is always set
return (
<OverlayScrollbar scrollRef={scrollRef} className="flex-1">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader />
</div>
) : (
{/* actual content */}
)}
</OverlayScrollbar>
);
| Pitfall | Why It Breaks | Fix |
|---|---|---|
| Saving scroll during restoration | Fresh mount fires scroll events with scrollTop=0, overwriting saved position |
isRestoringRef guard blocks saves during settling window |
Single-shot restoration (hasRestoredRef) |
Restores once before async content renders, then stops | Settling window keeps retrying for 1.5s |
isActive transition detection |
Component unmounts/remounts, so wasActiveRef always starts fresh — transition is never detected |
Don't use isActive; rely on mount-time restoration |
| Rendering OverlayScrollbar conditionally | scrollRef.current is null when the restoration effect runs |
Always keep OverlayScrollbar in the tree; swap children instead |
The hook has a DEBUG flag at the top of useTabScrollPersistence.ts. Set it to true to see [ScrollPersistence] logs in the console showing:
OverlayScrollbar (not native overflow-y-auto) for the scroll containerscrollRef from useTabScrollPersistence(tabId) to OverlayScrollbarOverlayScrollbar in the component tree during ALL render states (loading, error, etc.)OverlayScrollbar, not as alternative returnsisActive parameter to the hook| File | Purpose |
|---|---|
src/hooks/useTabScrollPersistence.ts |
Hook that saves/restores scroll position per tab |
src/components/OverlayScrollbar.tsx |
Custom scrollbar with scrollRef prop |
src/features/notes/note-view.tsx |
Reference implementation |
src/features/chat/chat-view.tsx |
Chat implementation with async history loading |