MacOS WKWebView Custom Scrollbars
The Problem
WKWebView on macOS does not support standard CSS scrollbar styling:
- •
::-webkit-scrollbarpseudo-elements are ignored - •
scrollbar-colorandscrollbar-widthCSS properties don't work reliably - •Native scrollbars always render with system appearance
This means CSS-based scrollbar theming that works in browsers will NOT work in the native macOS app.
The Solution: Negative Margin Technique
Hide the native scrollbar using pure CSS layout (not pseudo-elements):
- •Outer wrapper:
overflow: hiddenclips the native scrollbar - •Inner scrollable div:
overflow-y: scroll+marginRight: -20pxpushes scrollbar outside - •Padding compensation:
paddingRight: 20pxensures content isn't cut off - •Custom overlay: Render a themed scrollbar as a positioned DOM element
Usage
Use 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>
Component Props
| 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 |
Features
- •Theme-aware: Uses
currentTheme.styles.borderDefaultfor scrollbar color - •Auto-hide: Scrollbar fades out after 1 second of inactivity
- •Hover to show: Scrollbar appears when hovering the container
- •Drag support: Click and drag the thumb to scroll
- •Track click: Click the track to jump to position
- •Resize-aware: Updates when content or container size changes
When to Use
Use OverlayScrollbar instead of native overflow-y-auto when:
- •The scroll container needs themed scrollbars
- •The component renders in the macOS WKWebView app
- •You want consistent scrollbar appearance across web and native
When NOT to Use
- •Very small scroll areas (the overlay adds complexity)
- •Performance-critical lists with thousands of items (consider virtualization)
- •Areas where native scrollbar behavior is preferred
Implementation Details
See the full component at: src/components/OverlayScrollbar.tsx
Key constants:
- •
SCROLLBAR_WIDTH = 20- Margin to hide native scrollbar (macOS scrollbar is ~15-17px) - •Thumb minimum height: 30px
- •Hide delay: 1000ms after scroll stops
- •Fade transition: 150ms
Scroll Position Persistence for Tabs
When implementing scroll persistence for workspace tabs, use useTabScrollPersistence with OverlayScrollbar.
How It Works
- •
useTabScrollPersistence(tabId)returns a ref and:- •Saves scroll position to a module-level Map on every scroll event
- •Restores position when the component mounts (using ResizeObserver/MutationObserver for async content)
- •
Pass the ref to
OverlayScrollbar:tsxconst scrollRef = useTabScrollPersistence(tabId); <OverlayScrollbar scrollRef={scrollRef} className="flex-1"> {/* content */} </OverlayScrollbar>
Critical Rule: Keep OverlayScrollbar Mounted
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>
);
Why This Matters
The useTabScrollPersistence hook runs its effect on mount with [tabId] dependency:
useEffect(() => {
const element = scrollRef.current;
if (!element) return; // Early return if ref not set!
// Set up observers and attempt restoration...
}, [tabId]);
If the element isn't mounted when the effect runs:
- •
scrollRef.currentisnull - •Effect returns early without setting up observers
- •When content loads and OverlayScrollbar mounts, the effect doesn't re-run
- •No scroll restoration happens
Checklist for Scroll Persistence
- • Use
OverlayScrollbar(not nativeoverflow-y-auto) for the scroll container - • Pass
scrollReffromuseTabScrollPersistencetoOverlayScrollbar - • Keep
OverlayScrollbarin the component tree during ALL render states (loading, error, etc.) - • Render loading/error states as CHILDREN of
OverlayScrollbar, not as alternative returns
Key Files
| 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 (lines 1370-1457) |
src/features/chat/chat-view.tsx | Chat implementation with loading state handling |