| name | macos-scrollbar |
| description | Custom themed scrollbars for macOS WKWebView apps. Use when styling scrollbars in the native macOS app, fixing scrollbar theming issues, implementing custom scroll containers that work in WKWebView, or debugging scroll position persistence issues with tabs. |
MacOS WKWebView Custom Scrollbars
The Problem
WKWebView on macOS does not support standard CSS scrollbar styling:
::-webkit-scrollbar pseudo-elements are ignored
scrollbar-color and scrollbar-width CSS 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: hidden clips the native scrollbar
- Inner scrollable div:
overflow-y: scroll + marginRight: -20px pushes scrollbar outside
- Padding compensation:
paddingRight: 20px ensures 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";
<OverlayScrollbar className="h-full">
<div>Your scrollable content here</div>
</OverlayScrollbar>
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.borderDefault for 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.
Critical Constraint: Radix TabsContent Unmounts Inactive Tabs
Radix UI's TabsContent (used by shadcn Tabs) unmounts content when the tab is not active (unless forceMount is set). This means:
- Switching away from a tab destroys the component and its DOM (including scroll containers)
- Switching back remounts the component fresh (new refs, new state, new effects)
- You CANNOT rely on
isActive state transitions to detect tab switches — the component always mounts fresh with isActive=true
This is why scroll position must be saved to a module-level Map (survives unmounts) rather than component state or refs.
How It Works
useTabScrollPersistence(tabId) returns a ref and manages two concerns:
Saving (scroll listener):
- Attaches a scroll event listener to save
scrollTop to a module-level Map<string, number>
- The listener is disabled during the restoration window to prevent overwriting the saved position with
scrollTop=0 from the fresh mount
Restoring (settling window):
- On mount, reads the saved position from the Map
- Tries to restore immediately, on the next animation frame, and on every DOM mutation/resize
- Keeps retrying for a 1.5-second settling window to handle async content (e.g., chat history loading via API)
- After the window closes, stops restoring and re-enables the scroll save listener
Usage
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).
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:
if (isLoading) {
return <Loader />;
}
return (
<OverlayScrollbar scrollRef={scrollRef}>
{/* content */}
</OverlayScrollbar>
);
return (
<OverlayScrollbar scrollRef={scrollRef} className="flex-1">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader />
</div>
) : (
{/* actual content */}
)}
</OverlayScrollbar>
);
Common Pitfalls
| 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 |
Debugging
The hook has a DEBUG flag at the top of useTabScrollPersistence.ts. Set it to true to see [ScrollPersistence] logs in the console showing:
- Mount: saved position and container dimensions
- Each restoration attempt and whether it succeeded
- When the settling window closes and the final scroll position
Checklist for Scroll Persistence
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 |
src/features/chat/chat-view.tsx | Chat implementation with async history loading |
References