diff --git a/docs/design.md b/docs/design.md
index 6ad423d..d83212f 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -248,7 +248,9 @@ control blurs, which rings it on `:focus`: a click or ⌘↵ doesn't count as ke
Every hover is the same faint brand tint: fills use `--pn-hover`, borders `--pn-hover-line`; never `--pn-muted`, the
solid accent or a status color. Selected and active states keep their own fills (`--pn-accent-soft`,
`--pn-nav-active`). Destructive colors identify actions at rest and validation errors; their hover is the same blue.
-Scrollable surfaces use thin, rounded scrollbars with the theme's line color on a transparent track, including portals.
+Converted scrollable surfaces use `CustomScrollbar`: a narrow, rounded thumb in the theme's line color on a transparent
+track, with pointer dragging, track clicks and keyboard navigation. Only those viewports hide native scrollbars;
+other scroll regions retain their native controls, including horizontal controls in zoomed PDF viewers.
Chip overflow counts open a themed popover on hover or click, listing full names on separate lines without truncation;
keyboard users open it with Enter or Space and dismiss it with Escape.
Tooltips use the same raised surface, foreground, border and corner radius as overflow popovers. Both open below
diff --git a/src/components/primitives/avatar-group.tsx b/src/components/primitives/avatar-group.tsx
index 63d537c..4ac541c 100644
--- a/src/components/primitives/avatar-group.tsx
+++ b/src/components/primitives/avatar-group.tsx
@@ -1,3 +1,4 @@
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { pluralize } from "@/lib/format"
@@ -80,7 +81,11 @@ export function AvatarGroup({ people, max = 7, listLabel = "Members", className
{pluralize(rest.length, "more member")}
-
+
{rest.map((person) => (
@@ -89,7 +94,7 @@ export function AvatarGroup({ people, max = 7, listLabel = "Members", className
))}
-
+
)}
diff --git a/src/components/primitives/chip-overflow.tsx b/src/components/primitives/chip-overflow.tsx
index b2ebd3c..589c01f 100644
--- a/src/components/primitives/chip-overflow.tsx
+++ b/src/components/primitives/chip-overflow.tsx
@@ -1,6 +1,7 @@
import type * as React from "react"
import { type ReactNode, useLayoutEffect, useRef, useState } from "react"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
@@ -121,14 +122,14 @@ export function ChipOverflow({
)}
>
Additional items
-
+
{rest.map((label, index) => (
{label}
))}
-
+
)}
diff --git a/src/components/primitives/confirm-dialog.tsx b/src/components/primitives/confirm-dialog.tsx
index 381f80c..bd4cad9 100644
--- a/src/components/primitives/confirm-dialog.tsx
+++ b/src/components/primitives/confirm-dialog.tsx
@@ -2,6 +2,7 @@ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog
import { type LucideIcon, TriangleAlert } from "lucide-react"
import { type ReactElement, type ReactNode, useRef, useState } from "react"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import {
AlertDialog,
AlertDialogCancel,
@@ -120,7 +121,7 @@ export function ConfirmDialog({
finalFocus={finalFocus ? () => finalFocus() ?? true : undefined}
className={cn(dialogPanel, dialogMotion, "max-w-[460px]")}
>
-
+
{Icon ? (
{description}
{error && {error} }
-
+
{cancelLabel}
diff --git a/src/components/primitives/custom-scrollbar.tsx b/src/components/primitives/custom-scrollbar.tsx
new file mode 100644
index 0000000..da238a2
--- /dev/null
+++ b/src/components/primitives/custom-scrollbar.tsx
@@ -0,0 +1,217 @@
+import {
+ type ComponentPropsWithoutRef,
+ type ElementType,
+ type KeyboardEvent,
+ type PointerEvent,
+ type ReactNode,
+ type Ref,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useId,
+ useRef,
+ useState,
+} from "react"
+
+import { cn } from "@/lib/utils"
+
+type ScrollMetrics = {
+ maxScroll: number
+ thumbSize: number
+ thumbOffset: number
+ trackSize: number
+}
+
+type CustomScrollbarProps = {
+ as?: E
+ /** Sizing and layout classes for the outer grid. */
+ className?: string
+ /** Classes for the actual scrollable viewport. */
+ viewportClassName?: string
+ /** Ref to the scrollable element. */
+ scrollRef?: Ref
+ /** Accessible label for the scrollbar control. */
+ scrollLabel?: string
+ children?: ReactNode
+} & Omit, "as" | "className" | "children" | "ref">
+
+/** A scroll area with a themed, draggable scrollbar instead of the browser's scrollbar. */
+export function CustomScrollbar({
+ as,
+ className,
+ viewportClassName,
+ scrollRef,
+ scrollLabel,
+ children,
+ ...props
+}: CustomScrollbarProps) {
+ // SAFETY: `as` accepts only React element types, and the fallback is a native div.
+ const ScrollElement = (as ?? "div") as ElementType
+ const localScrollRef = useRef(null)
+ const trackRef = useRef(null)
+ const dragRef = useRef<{ pointerId: number; startY: number; startScroll: number } | null>(null)
+ const generatedId = useId()
+ const [metrics, setMetrics] = useState({ maxScroll: 0, thumbSize: 0, thumbOffset: 0, trackSize: 0 })
+ useImperativeHandle(scrollRef, () => {
+ const element = localScrollRef.current
+ if (!element) throw new Error("Custom scrollbar viewport did not mount.")
+ return element
+ }, [scrollRef])
+
+ const measure = useCallback(() => {
+ const element = localScrollRef.current
+ const track = trackRef.current
+ if (!element || !track) return
+
+ const maxScroll = Math.max(0, element.scrollHeight - element.clientHeight)
+ const trackSize = track.clientHeight
+ const thumbSize =
+ maxScroll > 0
+ ? Math.min(trackSize, Math.max(24, (trackSize * element.clientHeight) / element.scrollHeight))
+ : trackSize
+ const thumbOffset = maxScroll > 0 ? ((trackSize - thumbSize) * element.scrollTop) / maxScroll : 0
+ setMetrics((current) =>
+ current.maxScroll === maxScroll &&
+ current.thumbSize === thumbSize &&
+ current.thumbOffset === thumbOffset &&
+ current.trackSize === trackSize
+ ? current
+ : { maxScroll, thumbSize, thumbOffset, trackSize }
+ )
+ }, [])
+
+ useEffect(() => {
+ const element = localScrollRef.current
+ if (!element) return
+
+ let frame = 0
+ const scheduleMeasure = () => {
+ cancelAnimationFrame(frame)
+ frame = requestAnimationFrame(measure)
+ }
+ const resizeObserver = new ResizeObserver(scheduleMeasure)
+ resizeObserver.observe(element)
+ if (element.firstElementChild) resizeObserver.observe(element.firstElementChild)
+ const mutationObserver = new MutationObserver(scheduleMeasure)
+ mutationObserver.observe(element, { childList: true, subtree: true, characterData: true, attributes: true })
+ element.addEventListener("scroll", scheduleMeasure, { passive: true })
+ window.addEventListener("resize", scheduleMeasure)
+ scheduleMeasure()
+
+ return () => {
+ cancelAnimationFrame(frame)
+ resizeObserver.disconnect()
+ mutationObserver.disconnect()
+ element.removeEventListener("scroll", scheduleMeasure)
+ window.removeEventListener("resize", scheduleMeasure)
+ }
+ }, [measure])
+
+ function onTrackPointerDown(event: PointerEvent) {
+ if (event.target !== event.currentTarget || metrics.maxScroll === 0) return
+ const element = localScrollRef.current
+ if (!element) return
+ const rect = event.currentTarget.getBoundingClientRect()
+ const clickPosition = event.clientY - rect.top
+ const targetPosition = Math.max(0, Math.min(metrics.trackSize, clickPosition - metrics.thumbSize / 2))
+ const travel = Math.max(1, metrics.trackSize - metrics.thumbSize)
+ element.scrollTop = (targetPosition / travel) * metrics.maxScroll
+ }
+
+ function onThumbPointerDown(event: PointerEvent) {
+ if (metrics.maxScroll === 0) return
+ event.preventDefault()
+ event.stopPropagation()
+ event.currentTarget.setPointerCapture(event.pointerId)
+ dragRef.current = {
+ pointerId: event.pointerId,
+ startY: event.clientY,
+ startScroll: localScrollRef.current?.scrollTop ?? 0,
+ }
+ }
+
+ function onThumbPointerMove(event: PointerEvent) {
+ const drag = dragRef.current
+ const element = localScrollRef.current
+ if (!drag || drag.pointerId !== event.pointerId || !element) return
+ const travel = Math.max(1, metrics.trackSize - metrics.thumbSize)
+ element.scrollTop = drag.startScroll + ((event.clientY - drag.startY) / travel) * metrics.maxScroll
+ }
+
+ function onThumbPointerEnd(event: PointerEvent) {
+ if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null
+ }
+
+ function onScrollbarKeyDown(event: KeyboardEvent) {
+ const element = localScrollRef.current
+ if (!element) return
+ const page = Math.max(40, element.clientHeight * 0.9)
+ const amount = Math.max(24, element.clientHeight * 0.1)
+ let nextPosition: number
+ switch (event.key) {
+ case "ArrowDown":
+ nextPosition = element.scrollTop + amount
+ break
+ case "ArrowUp":
+ nextPosition = element.scrollTop - amount
+ break
+ case "PageDown":
+ nextPosition = element.scrollTop + page
+ break
+ case "PageUp":
+ nextPosition = element.scrollTop - page
+ break
+ case "Home":
+ nextPosition = 0
+ break
+ case "End":
+ nextPosition = metrics.maxScroll
+ break
+ default:
+ return
+ }
+ event.preventDefault()
+ element.scrollTop = nextPosition
+ }
+
+ return (
+
+
+ {children}
+
+
0 ? 0 : -1}
+ onKeyDown={onScrollbarKeyDown}
+ onPointerDown={onTrackPointerDown}
+ className="custom-scroll-area__track"
+ data-visible={metrics.maxScroll > 0 ? "" : undefined}
+ >
+
+
+
+ )
+}
diff --git a/src/components/primitives/form-dialog.tsx b/src/components/primitives/form-dialog.tsx
index 1654875..08a2cb2 100644
--- a/src/components/primitives/form-dialog.tsx
+++ b/src/components/primitives/form-dialog.tsx
@@ -2,6 +2,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { type LucideIcon, TriangleAlert, X } from "lucide-react"
import { type KeyboardEvent, type ReactElement, type ReactNode, useRef, useState } from "react"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -103,7 +104,7 @@ export function FormDialog({
const [submitError, setSubmitError] = useState(null)
const [confirmingDiscard, setConfirmingDiscard] = useState(false)
const popupRef = useRef(null)
- const bodyRef = useRef(null)
+ const bodyRef = useRef(null)
const shownError = error ?? submitError
function requestClose() {
@@ -181,9 +182,13 @@ export function FormDialog({
onKeyDown={onFormKeyDown}
className="flex min-h-0 flex-1 flex-col"
>
-
+
{children}
-
+
{(shownError || notice) && (
{notice}
diff --git a/src/components/primitives/label-tree-selector.tsx b/src/components/primitives/label-tree-selector.tsx
index 320e243..407be63 100644
--- a/src/components/primitives/label-tree-selector.tsx
+++ b/src/components/primitives/label-tree-selector.tsx
@@ -1,6 +1,7 @@
import { Check, ChevronRight, Minus, Search, X } from "lucide-react"
import { useMemo, useState } from "react"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import {
buildLabelTree,
collectSubtreeLabels,
@@ -204,7 +205,7 @@ export function LabelTreeSelector({
{selectedLabels.length > 0 && (
// Own block under the search: two 24px lines (plus the 4px gap) then it scrolls; 8px below the chips.
-
+
{selectedLabels.map((label) => (
))}
-
+
)}
-
+
{showTree && (
Categories
@@ -266,7 +267,7 @@ export function LabelTreeSelector({
className="px-2 py-6"
/>
)}
-
+
)
}
diff --git a/src/components/shell/command-palette.tsx b/src/components/shell/command-palette.tsx
index a3ec4fe..ab57d62 100644
--- a/src/components/shell/command-palette.tsx
+++ b/src/components/shell/command-palette.tsx
@@ -4,6 +4,7 @@ import { Command } from "cmdk"
import { LogOut, type LucideIcon, Moon, Search, Sun, UserRound } from "lucide-react"
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog"
import { suppressNextFocusTooltip } from "@/components/ui/tooltip"
@@ -159,30 +160,36 @@ export function CommandPalette({ open, onOpenChange, onSignOut }: CommandPalette
className="h-full min-w-0 flex-1 bg-transparent text-[14px] text-(--pn-fg) outline-none placeholder:text-(--pn-fg-subtle)"
/>
-
-
- Nothing matches “{query}”.
-
- {groups.map((group) => (
-
- {group.entries.map((entry) => (
- run(entry)}
- className="flex h-10 cursor-default items-center gap-3 rounded-(--pn-r-2) px-2 text-[14px] select-none data-[selected=true]:bg-(--pn-muted)"
- >
- {entry.glyph}
- {entry.label}
-
- ))}
-
- ))}
-
+
+
+
+ Nothing matches “{query}”.
+
+ {groups.map((group) => (
+
+ {group.entries.map((entry) => (
+ run(entry)}
+ className="flex h-10 cursor-default items-center gap-3 rounded-(--pn-r-2) px-2 text-[14px] select-none data-[selected=true]:bg-(--pn-muted)"
+ >
+ {entry.glyph}
+ {entry.label}
+
+ ))}
+
+ ))}
+
+
diff --git a/src/components/shell/panel.tsx b/src/components/shell/panel.tsx
index bbd3487..ae75518 100644
--- a/src/components/shell/panel.tsx
+++ b/src/components/shell/panel.tsx
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router"
import { type KeyboardEvent, Suspense, use, useDeferredValue, useLayoutEffect, useRef } from "react"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { formatNumber } from "@/lib/format"
import { cn } from "@/lib/utils"
@@ -125,13 +126,16 @@ export function Panel({ service, match, pending, className }: PanelProps) {
{service.title}
-
-
+
)
}
diff --git a/src/components/shell/shell.tsx b/src/components/shell/shell.tsx
index b6a219e..d8c3b19 100644
--- a/src/components/shell/shell.tsx
+++ b/src/components/shell/shell.tsx
@@ -2,6 +2,7 @@ import { Outlet } from "@tanstack/react-router"
import { animate, AnimatePresence, motion, type Transition, useReducedMotion } from "motion/react"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { TOOLTIP_DELAY } from "@/components/primitives/hint"
import { TooltipProvider } from "@/components/ui/tooltip"
import type { DashboardUser } from "@/lib/auth"
@@ -194,11 +195,13 @@ function DashboardFrame({ user: dashboardUser, pending }: DashboardShellProps) {
{/* One scroll container per page: keyed so each page starts at the top, and back/forward restore it. */}
-
{hasPanel ? (
@@ -206,7 +209,7 @@ function DashboardFrame({ user: dashboardUser, pending }: DashboardShellProps) {
-
+
diff --git a/src/features/groups-by-label/add-group-to-label-dialog.tsx b/src/features/groups-by-label/add-group-to-label-dialog.tsx
index 8f27d8c..26b8337 100644
--- a/src/features/groups-by-label/add-group-to-label-dialog.tsx
+++ b/src/features/groups-by-label/add-group-to-label-dialog.tsx
@@ -16,6 +16,7 @@ import {
SectionEmpty,
SegmentedControl,
} from "@/components/primitives"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { Hint } from "@/components/primitives/hint"
import { appToast, useCan } from "@/components/shell"
import { Button } from "@/components/ui/button"
@@ -356,9 +357,11 @@ export function AddGroupToLabelDialog({
{selected.length > 0 && (
-
{selected.length} selected
@@ -380,13 +383,14 @@ export function AddGroupToLabelDialog({
))}
-
+
)}
-
{pickable.length > 0 ? (
pickable.map((group) => {
@@ -414,7 +418,7 @@ export function AddGroupToLabelDialog({
) : (
)}
-
+
>
)}
diff --git a/src/features/groups/labels-filter-popover.tsx b/src/features/groups/labels-filter-popover.tsx
index 25d48f3..1776efb 100644
--- a/src/features/groups/labels-filter-popover.tsx
+++ b/src/features/groups/labels-filter-popover.tsx
@@ -17,6 +17,7 @@ import {
type TablePagination,
type TableSort,
} from "@/components/primitives"
+import { CustomScrollbar } from "@/components/primitives/custom-scrollbar"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import type { GroupLabel } from "@/features/group-labels/types"
@@ -326,10 +327,12 @@ export function LabelsFilterPopover({ labels, value, onChange, groupCount }: Lab
className={cn(
raisedSurface,
floatingMotion,
- "max-h-(--available-height) w-80 gap-3 overflow-y-auto rounded-(--pn-r-4) p-3 max-lg:max-w-[calc(100vw-2rem)]"
+ "max-h-(--available-height) w-80 overflow-hidden rounded-(--pn-r-4) p-0 max-lg:max-w-[calc(100vw-2rem)]"
)}
>
- {content}
+
+ {content}
+
)
diff --git a/src/styles.css b/src/styles.css
index 97b0bdb..85ab7be 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -5,34 +5,72 @@
@custom-variant dark (&:is(.dark *));
-/* Scrolling surfaces and portals share the dashboard palette in both themes. */
-* {
- scrollbar-width: thin;
- scrollbar-color: var(--pn-line-strong) transparent;
+.custom-scroll-area {
+ position: relative;
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ grid-template: minmax(0, 1fr) / minmax(0, 1fr);
}
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
+.custom-scroll-area__viewport {
+ grid-area: 1 / 1;
+ min-width: 0;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ scrollbar-width: none;
}
-::-webkit-scrollbar-track {
- background: transparent;
+.custom-scroll-area__viewport::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+.custom-scroll-area__track {
+ z-index: 1;
+ grid-area: 1 / 1;
+ justify-self: end;
+ align-self: stretch;
+ width: 12px;
+ margin: 2px 1px;
+ padding: 0;
+ overflow: hidden;
+ cursor: pointer;
+ touch-action: none;
+ user-select: none;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms var(--pn-ease-out);
+}
+
+.custom-scroll-area__track[data-visible] {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.custom-scroll-area__track:focus-visible {
+ outline: 2px solid var(--pn-focus);
+ outline-offset: -1px;
+ border-radius: 999px;
}
-::-webkit-scrollbar-thumb {
- border: 2px solid transparent;
+.custom-scroll-area__thumb {
+ width: 4px;
+ margin-inline: auto;
border-radius: 999px;
background: var(--pn-line-strong);
- background-clip: padding-box;
+ cursor: grab;
}
-::-webkit-scrollbar-thumb:hover {
- background-color: var(--pn-fg-subtle);
+.custom-scroll-area__track:hover .custom-scroll-area__thumb {
+ width: 6px;
+ background: var(--pn-fg-subtle);
}
-::-webkit-scrollbar-corner {
- background: transparent;
+.custom-scroll-area__thumb:active {
+ cursor: grabbing;
}
/* The shell breakpoint (760px): `shell:` gets the rail and panel, `max-shell:` the top bar, pills and bottom nav. */