fix(lightbox): smoother swipe and consistent top/bottom padding

Swipe: replace React state-driven translation (caused re-renders + jarring
snap-back) with direct DOM mutation via ref. Axis is locked on first
significant movement; at edges resistance is 20% rubber-band. On release,
if past the 30% threshold the next/prev fires and the position resets
without animation; otherwise it springs back with a single cubic-bezier
transition.

Bottom bar: matches top bar rhythm (px-4 py-3 + safe-area-bottom).
Gradient scrim is now a fixed h-32 pseudo-element that extends upward from
the bottom of the bar, independent of the content height, so it fades
cleanly over the image without needing extra pt-10 padding on the strip.
This commit is contained in:
Chad Curtis
2026-03-02 17:40:42 -06:00
parent 1cb05e4212
commit ad0efdff60
2 changed files with 60 additions and 24 deletions
+55 -19
View File
@@ -315,9 +315,6 @@ export interface LightboxProps {
export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, topBarLeft, showDownload = true, maxDotIndicators = 10, bottomBar, onNextEvent, onPrevEvent }: LightboxProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchDelta, setTouchDelta] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const currentUrl = images[currentIndex];
const hasMultiple = images.length > 1;
@@ -370,25 +367,67 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, topBar
};
}, []);
// Touch handlers for swipe
// ── Swipe gesture — direct DOM mutation for zero-jank 1:1 tracking ──────────
const imageWrapRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
const lockedAxis = useRef<'h' | 'v' | null>(null);
const SNAP_THRESHOLD = 0.3; // fraction of viewport width
const setTranslate = (px: number, animated: boolean) => {
const el = imageWrapRef.current;
if (!el) return;
el.style.transition = animated ? 'transform 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94)' : 'none';
el.style.transform = px === 0 ? '' : `translateX(${px}px)`;
};
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.touches[0].clientX);
setIsDragging(true);
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
lockedAxis.current = null;
setTranslate(0, false);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (touchStart === null) return;
setTouchDelta(e.touches[0].clientX - touchStart);
if (touchStartX.current === null || touchStartY.current === null) return;
const dx = e.touches[0].clientX - touchStartX.current;
const dy = e.touches[0].clientY - touchStartY.current;
// Determine dominant axis on first significant movement
if (!lockedAxis.current) {
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
lockedAxis.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
}
if (lockedAxis.current !== 'h') return;
e.preventDefault();
// Rubber-band resistance at edges
const atEdge = (dx > 0 && !canGoPrev) || (dx < 0 && !canGoNext);
setTranslate(atEdge ? dx * 0.2 : dx, false);
};
const handleTouchEnd = () => {
if (Math.abs(touchDelta) > 60) {
if (touchDelta > 0 && canGoPrev) handlePrev();
else if (touchDelta < 0 && canGoNext) handleNext();
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null || lockedAxis.current !== 'h') {
touchStartX.current = null;
lockedAxis.current = null;
setTranslate(0, true);
return;
}
const dx = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
lockedAxis.current = null;
const threshold = window.innerWidth * SNAP_THRESHOLD;
if (dx < -threshold && canGoNext) {
handleNext();
setTranslate(0, false); // parent will swap image; just reset without animation
} else if (dx > threshold && canGoPrev) {
handlePrev();
setTranslate(0, false);
} else {
setTranslate(0, true); // spring back
}
setTouchStart(null);
setTouchDelta(0);
setIsDragging(false);
};
// Click anywhere that isn't a button or the image itself to close
@@ -481,11 +520,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, topBar
{/* Image — fills the entire viewport; top/bottom space is consumed by the top bar overlay */}
<div
ref={imageWrapRef}
className="relative z-[1] flex items-center justify-center w-full h-full"
style={{
transform: isDragging ? `translateX(${touchDelta}px)` : undefined,
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
>
{/* Spinner while loading */}
{!isLoaded && (
+5 -5
View File
@@ -52,12 +52,12 @@ export function PhotoBottomBar({ event, onCommentClick, commentsEvent, onComment
return (
<>
{/* Gradient scrim + action strip */}
<div className="relative pb-[env(safe-area-inset-bottom,0px)]">
{/* Gradient scrim */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
{/* Action strip — mirrors top bar: px-4 py-3 + safe-area */}
<div className="relative safe-area-bottom">
{/* Gradient scrim: tall enough to fade nicely over the image above the bar */}
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
<div className="relative flex items-center gap-1 px-3 pt-10 pb-3 max-w-xl mx-auto">
<div className="relative flex items-center gap-1 px-4 py-3 max-w-xl mx-auto">
{/* Avatar + name */}
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0">