Polish the Hub card carousel: edge shadows, fades, and drag-to-scroll (#6461)

* Fix clipped card shadows in Hub trending carousel

The carousel scroller only had vertical padding, so with overflow-x set
the first and last cards had their drop shadow clipped on the horizontal
edges. Add px-2 with a matching -mx-2 so the shadow has room while the
cards stay aligned with the section heading, and scroll-px-2 so snap-start
does not scroll the padding away on load.

* Align carousel edge fades with the scroll clip edge

The shadow fix gave the scroller an -mx-2 bleed, but the left/right fade
overlays stayed pinned to the wrapper edges, 8px inside the clip edge. That
left a thin strip where a card showed beside the fade, so the fade read as a
separate block instead of blending into the background. Offset both fades by
the same 8px so their opaque edge sits on the clip edge.

* Add click-and-drag panning to the Hub card carousel

The rows only scrolled by wheel or trackpad, and grabbing a card started
a native drag of its avatar image, so the cards could not be dragged to
move the row. Add mouse drag-to-scroll (touch and pen keep native
scrolling), swallow the click a drag would otherwise fire on a card, keep
plain clicks working, and block the avatar's native drag.

* Trim carousel edge fade width from 56px to 44px

* Smooth out carousel drag panning

Scroll snap was correcting the position on every drag frame, which made
the pan feel sticky. Disable snap while a drag is active and restore it on
release so the row follows the pointer and then settles on a card.

* Drop stale carousel drag when the button is released off-element

If a press ended outside the scroller before the drag threshold was
crossed, no pointerup reached us and the drag stayed armed, so a later
buttonless mousemove would scroll the row. Bail out and clear the drag
whenever the primary button is no longer held.
This commit is contained in:
Michael Han 2026-06-19 01:54:33 -07:00 committed by GitHub
commit aec27263e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 75 additions and 4 deletions

View file

@ -5,6 +5,8 @@ import { cn } from "@/lib/utils";
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
@ -102,13 +104,79 @@ export function CardCarousel<T>({
[stepPx],
);
// Click-and-drag panning (mouse only; touch/pen keep native scrolling).
const drag = useRef<{ id: number; x: number; left: number; moved: boolean } | null>(
null,
);
const suppressClick = useRef(false);
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
suppressClick.current = false;
const el = scrollerRef.current;
if (!el || e.pointerType !== "mouse" || e.button !== 0) return;
drag.current = { id: e.pointerId, x: e.clientX, left: el.scrollLeft, moved: false };
}, []);
const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
const d = drag.current;
const el = scrollerRef.current;
if (!d || !el || e.pointerId !== d.id) return;
// Primary button no longer held: the press ended off the scroller, so no
// pointerup reached us. Drop the stale drag instead of scrolling on hover.
if ((e.buttons & 1) === 0) {
if (d.moved) el.style.scrollSnapType = "";
drag.current = null;
return;
}
const dx = e.clientX - d.x;
// Ignore tiny moves so plain clicks still register.
if (!d.moved && Math.abs(dx) < 5) return;
if (!d.moved) {
d.moved = true;
// Snap fights the per-frame scrollLeft writes; disable it while dragging.
el.style.scrollSnapType = "none";
el.setPointerCapture(d.id);
}
el.scrollLeft = d.left - dx;
}, []);
const endDrag = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || e.pointerId !== d.id) return;
if (d.moved) {
// A drag just happened: swallow the click it would fire on a card.
suppressClick.current = true;
const el = scrollerRef.current;
// Restore snap so the row settles on a card after the drag.
if (el) el.style.scrollSnapType = "";
el?.releasePointerCapture?.(d.id);
}
drag.current = null;
}, []);
const onClickCapture = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
if (!suppressClick.current) return;
suppressClick.current = false;
e.preventDefault();
e.stopPropagation();
}, []);
return (
<div className="relative">
<div
ref={scrollerRef}
onScroll={updateArrows}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={onClickCapture}
// Stop the avatar image from starting a native drag during a pan.
onDragStart={(e) => e.preventDefault()}
aria-label={ariaLabel}
className="hub-carousel flex snap-x gap-4 overflow-x-auto pb-4 pt-2"
// px-2 + -mx-2 give card shadows room so the edge cards aren't clipped;
// scroll-px-2 keeps snap-start aligned with the heading.
className="hub-carousel -mx-2 flex cursor-grab snap-x scroll-px-2 gap-4 overflow-x-auto px-2 pb-4 pt-2 select-none active:cursor-grabbing"
>
{items.map((item) => (
<div

View file

@ -147,7 +147,7 @@
pointer-events: none;
position: absolute;
z-index: 5;
width: 56px;
width: 44px;
opacity: 0;
transition: opacity 240ms ease;
}
@ -157,7 +157,9 @@
}
.hub-page .hub-carousel-fade-left {
left: 0;
/* -8px matches the scroller's -mx-2 bleed so the opaque edge sits on the
clip edge and no card peeks out beside the fade. */
left: -8px;
background: linear-gradient(
to right,
var(--background),
@ -167,7 +169,8 @@
}
.hub-page .hub-carousel-fade-right {
right: 0;
/* Mirror of fade-left: offset by the -mx-2 bleed to reach the clip edge. */
right: -8px;
background: linear-gradient(
to left,
var(--background),