From db1d6bbed1e174d072500e59a06ab0a72ad57e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Wed, 21 Oct 2026 18:59:51 +0000 Subject: [PATCH 1/2] fix(ui): replace native scrollbars with custom controls --- docs/design.md | 3 +- src/components/primitives/avatar-group.tsx | 9 +- src/components/primitives/chip-overflow.tsx | 5 +- src/components/primitives/confirm-dialog.tsx | 5 +- .../primitives/custom-scrollbar.tsx | 217 ++++++++++++++++++ src/components/primitives/form-dialog.tsx | 11 +- .../primitives/label-tree-selector.tsx | 9 +- src/components/shell/command-palette.tsx | 55 +++-- src/components/shell/panel.tsx | 10 +- src/components/shell/shell.tsx | 11 +- .../add-group-to-label-dialog.tsx | 16 +- src/features/groups/labels-filter-popover.tsx | 7 +- src/styles.css | 73 ++++-- 13 files changed, 363 insertions(+), 68 deletions(-) create mode 100644 src/components/primitives/custom-scrollbar.tsx diff --git a/docs/design.md b/docs/design.md index ac59c130..c9e515cc 100644 --- a/docs/design.md +++ b/docs/design.md @@ -235,7 +235,8 @@ invalid fields show the danger border. Opt out only with `data-focus-ring="none" 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. +Native browser scrollbars are hidden. Long 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. 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 63d537c1..4ac541c9 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")}

- + )} diff --git a/src/components/primitives/chip-overflow.tsx b/src/components/primitives/chip-overflow.tsx index f4de4ee0..aa41de92 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" @@ -99,14 +100,14 @@ export function ChipOverflow({ )} > Additional items - + )} diff --git a/src/components/primitives/confirm-dialog.tsx b/src/components/primitives/confirm-dialog.tsx index 8fa80d5d..16b15727 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, @@ -116,7 +117,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 00000000..da238a29 --- /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 4cb219f4..df600147 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 { 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, @@ -91,7 +92,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() { @@ -161,9 +162,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 320e2437..407be63b 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 a3ec4fe1..ab57d622 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 5fae4e01..fc27df9d 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 { cn } from "@/lib/utils" import { @@ -124,13 +125,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 b6a219e5..d8c3b194 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 8f27d8ca..26b8337b 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 25d48f31..1776efbd 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 5ad76be0..e4d6cf4f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5,34 +5,77 @@ @custom-variant dark (&:is(.dark *)); -/* Scrolling surfaces and portals share the dashboard palette in both themes. */ +/* Hide native scrollbars. Scrollable dashboard surfaces use the custom component below. */ * { - scrollbar-width: thin; - scrollbar-color: var(--pn-line-strong) transparent; + scrollbar-width: none; } -::-webkit-scrollbar { - width: 8px; - height: 8px; +*::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.custom-scroll-area { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + grid-template: minmax(0, 1fr) / minmax(0, 1fr); } -::-webkit-scrollbar-track { - background: transparent; +.custom-scroll-area__viewport { + grid-area: 1 / 1; + min-width: 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; +} + +.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. */ From f39486080576b3871d44baed9e9039e1e1901856 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 22 Oct 2026 22:06:35 +0200 Subject: [PATCH 2/2] fix(ui): retain scrollbars on unconverted scroll regions --- docs/design.md | 5 +++-- src/styles.css | 17 ++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/design.md b/docs/design.md index 01e34aee..140924d9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -247,8 +247,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. -Native browser scrollbars are hidden. Long 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. +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/styles.css b/src/styles.css index 5c977224..85ab7be9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5,17 +5,6 @@ @custom-variant dark (&:is(.dark *)); -/* Hide native scrollbars. Scrollable dashboard surfaces use the custom component below. */ -* { - scrollbar-width: none; -} - -*::-webkit-scrollbar { - display: none; - width: 0; - height: 0; -} - .custom-scroll-area { position: relative; display: grid; @@ -33,6 +22,12 @@ scrollbar-width: none; } +.custom-scroll-area__viewport::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + .custom-scroll-area__track { z-index: 1; grid-area: 1 / 1;