MacOS WKWebView Custom Scrollbars
The Problem
WKWebView on macOS does not support standard CSS scrollbar styling:
::-webkit-scrollbarpseudo-elements are ignoredscrollbar-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:const 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 |
微信扫一扫