Skip to content
ENTRY_BUILDING_THE_STACK

Building the Receipt Stack

PUBLISHEDJANUARY 10, 2026
STATUS
ARCHIVED
CHANNELPUBLIC_LOG
VERSION2026.01

The Problem

I wanted navigation to feel physical. Clicking links is fine, but I was drawn to the idea of swiping through pages like shuffling a deck of cards. The challenge: this portfolio has scrollable content on each page. A horizontal swipe to navigate and a vertical scroll to read need to coexist without fighting each other.

This led to building what I call the "Receipt Stack"—three cards you can flick through, each representing a section of the site. Getting here took two prototypes and a lot of gesture tuning.

Prototype 1: Click to Shuffle

The first version was intentionally simple. No dragging, just clicks. I wanted to nail the card positioning and animation before adding complexity.

// Click a non-front card to bring it forward
onClick={!isFront ? () => {
  setOrder((prev) => {
    return [cardIndex, ...prev.filter((i) => i !== cardIndex)];
  });
} : undefined}

Each card gets a deterministic offset based on its position in the stack. I used a seeded random function so the offsets feel organic but stay consistent across renders:

const seededRandom = (seed: number) => {
  const x = Math.sin(seed * 9999) * 10000;
  return x - Math.floor(x);
};

const getOffset = (position: number) => {
  const direction = position % 2 === 0 ? -1 : 1;
  const baseX = direction * (30 + position * 15);
  return {
    x: baseX + (seededRandom(position * 1.1) - 0.5) * 20,
    y: (seededRandom(position * 2.2) - 0.5) * 20 + position * 5,
    rotate: direction * (3 + seededRandom(position * 3.3) * 5),
  };
};

The click prototype worked. Cards fanned out on hover, clicking a back card brought it forward with a satisfying spring animation. But it felt passive—I was clicking, not handling the cards.

You can still play with this version at /artifacts/card-stack-click.

Prototype 2: Drag to Shuffle

The second prototype added dragging. Flick the front card hard enough (velocity > 300) or drag it far enough (offset > 80px), and it shuffles to the back.

const handleDragEnd = (_, info: PanInfo) => {
  const shouldShuffle =
    Math.abs(info.velocity.x) > 300 ||
    Math.abs(info.velocity.y) > 300 ||
    Math.abs(info.offset.x) > 80 ||
    Math.abs(info.offset.y) > 80;

  if (shouldShuffle) shuffle();
};

This version lives at /artifacts/card-stack. It felt great in isolation—but I couldn't use it for real navigation. The moment you try to scroll the content inside a card, the drag handler steals the gesture.

The Intent Gatekeeper

The final system needed to answer a question every time you touch the screen: are you trying to scroll or swipe?

I call the solution the "Intent Gatekeeper." It works in three phases:

Phase 1: Wait for Movement

When a pointer goes down, I don't start the drag immediately. I check if the target is an interactive element (link, button, etc.) and skip gesture handling if so. Otherwise, I just record the starting position and wait:

const handlePointerDown = (e: React.PointerEvent) => {
  const target = e.target as HTMLElement;
  const isInteractive = target.closest(
    'a[href], button, input, textarea, select, [role="link"], [role="button"]'
  );
  
  if (isInteractive) {
    return; // Let the click work normally
  }
  
  gestureStartRef.current = { x: e.clientX, y: e.clientY };
  dragUnlockedRef.current = false;
  scrollCommittedRef.current = false;
};

Phase 2: Detect Intent

As the pointer moves, I calculate how far it's traveled in each direction. After 8 pixels of movement (STACK_CONFIG.gesture.intentThresholdPx), I make the call:

const dx = Math.abs(e.clientX - gestureStartRef.current.x);
const dy = Math.abs(e.clientY - gestureStartRef.current.y);

if (dx > STACK_CONFIG.gesture.intentThresholdPx || dy > STACK_CONFIG.gesture.intentThresholdPx) {
  const isNearlyPureVertical = dy > dx * VERTICAL_CONE_RATIO;

  if (isNearlyPureVertical) {
    scrollCommittedRef.current = true; // Commit to scroll
  } else {
    dragUnlockedRef.current = true;
    setTouchAction("none");
    dragControls.start(e, { snapToCursor: false });
  }
}

The key insight is the vertical cone. Rather than a simple "more horizontal than vertical" check, I define a cone of 15 degrees from pure vertical. Any movement within that cone is considered scrolling intent. This makes vertical scrolling feel natural even when your finger drifts slightly left or right.

const VERTICAL_CONE_RATIO = 1 / Math.tan(
  (STACK_CONFIG.gesture.verticalConeDegrees * Math.PI) / 180
);

Phase 3: Handle the Gesture

If the intent was horizontal, the drag proceeds normally. A velocity above 50 triggers navigation:

const handleDragEnd = (_, info: PanInfo) => {
  const velocity = Math.sqrt(info.velocity.x ** 2 + info.velocity.y ** 2);
  const isHorizontalEnough =
    Math.abs(info.velocity.x) >
    Math.abs(info.velocity.y) * STACK_CONFIG.gesture.horizontalVelocityRatio;

  if (velocity > STACK_CONFIG.gesture.flickVelocityThreshold && isHorizontalEnough) {
    const currentOrder = getOrderFromRoute(route);
    const nextRoute = currentOrder[1];
    window.scrollTo({ top: 0, behavior: "instant" });
    router.push(hrefForRoute(nextRoute));
  }
};

If the intent was vertical, the system commits to scroll and lets the browser handle it. The card never locks—native scrolling just works. This replaced an earlier approach where I tried to lock the card to Y-axis dragging, which felt clunky.

The stack needs to stay in sync with the URL. When you click a navbar link or use browser back/forward, the cards should reorder to match. The system derives the card order directly from the current route:

function classifyPath(pathname: string) {
  const route = STACK_ROUTES.find((r) => r.match(pathname))?.id ?? "home";
  const routeConfig = STACK_ROUTES.find((r) => r.id === route)!;
  const isSubpage = pathname !== routeConfig.href;
  return { route, isSubpage, lockStackInteractions: isSubpage };
}

const getOrderFromRoute = (currentRoute: RouteId): [RouteId, RouteId, RouteId] => {
  const routes: RouteId[] = ["home", "thoughts", "artifacts"];
  const currentIndex = routes.indexOf(currentRoute);
  return [
    routes[currentIndex],
    routes[(currentIndex + 1) % 3],
    routes[(currentIndex + 2) % 3],
  ];
};

This route-first approach eliminates state sync issues. The order is always derived from the URL, never stored separately. When rotating forward or backward, the callbacks compute the next route from the current one:

const rotateForward = useCallback(() => {
  const currentOrder = getOrderFromRoute(route);
  const nextRoute = currentOrder[1];
  window.scrollTo({ top: 0, behavior: "instant" });
  router.push(hrefForRoute(nextRoute));
}, [route, router]);

Collapsed Mode

When you navigate to a subpage (like /thoughts/building-the-stack), the stack collapses. Back cards slide offscreen, and the front card becomes a clickable overlay positioned at the bottom of the viewport. Clicking it returns you to the parent route.

const getCardAnimation = (isFront: boolean, isSubpage: boolean, offset, breathe, position) => {
  if (isFront) {
    return isSubpage ? { x: 0, rotate: 0, scale: 1 } : { x: 0, y: 0, rotate: 0, scale: 1 };
  }
  // When collapsed, back cards slide offscreen
  if (isSubpage) {
    return { x: 0, y: 0, rotate: 0, scale: 1 };
  }
  // When expanded, back cards have offset, rotation, and scale
  return {
    x: offset.x * breathe,
    y: offset.y * breathe,
    rotate: offset.rotate * breathe,
    scale: 1 - position * STACK_CONFIG.animation.scaleReductionPerPosition,
  };
};

The front card animates to a fixed position at calc(100dvh - 1.5rem) when collapsed. On hover-capable devices, it lifts slightly (-0.5rem) to signal interactivity. This gives clear visual feedback that you can return to the main view without cluttering the subpage content.

Tuning Constants

Getting gestures to feel right required a lot of iteration. I extracted all the magic numbers into a single configuration object, organized by concern:

const STACK_CONFIG = {
  gesture: {
    flickVelocityThreshold: 50,
    intentThresholdPx: 8,
    verticalConeDegrees: 15,
    horizontalVelocityRatio: 0.5,
    dragUnlockResetDelayMs: 50,
    dragElasticity: 1,
  },
  animation: {
    hoverSpreadMultiplier: 1.5,
    scaleReductionPerPosition: 0.01,
  },
  layout: {
    mobileBreakpoint: 768,
    minHeightMobile: 600,
    minHeightDesktop: 800,
  },
  style: {
    frontCardShadow: "0 12px 24px rgba(0,0,0,0.2)",
    backCardShadow: "0 4px 8px rgba(0,0,0,0.1)",
  },
} as const;

Grouping related values makes tuning faster. When something feels off, I know exactly where to look. The velocity threshold started at 300 (from prototype 2), dropped to 150, then settled at 50 after testing. At 50, even light flicks register while accidental taps still don't trigger navigation.

The vertical cone started at 10 degrees and moved to 15 after testing on touch devices—fingers naturally wobble a bit when scrolling.

What I Learned

Start with constraints removed. The click-only prototype let me focus on positioning and animation without worrying about gesture conflicts. The drag prototype confirmed the interaction model. Only then did I tackle the hard problem of distinguishing scroll from swipe.

Intent detection beats mode switching. Early attempts used a "scroll mode" toggle. It felt clunky. The intent gatekeeper reacts to what you're actually doing, not what mode you've selected.

Extract your magic numbers. Having all the tuning constants in one place made iteration fast. When something felt off on mobile, I knew exactly where to look. Grouping them into a configuration object made the relationships between values clearer—gesture thresholds live together, animation values are separate, layout breakpoints are grouped.

Polish adds depth. Differentiated shadows (deeper for front card, subtle for back cards) create visual hierarchy. Better interactive element detection ensures links and buttons work reliably even when the drag system is active. Scroll-to-top on route change prevents disorientation when switching cards.

The receipt stack now powers the main navigation of this site. Swipe through the cards or click the ones behind. Scroll the content naturally. The two gestures coexist because the system decides what you meant before committing to either. On subpages, the stack collapses to get out of the way while keeping a clear path back.

CERTIFICATION
"This entry represents a point-in-time reflection from the personal archives of BT Norris. The thoughts contained herein are subject to evolution and iteration."
IDENTIFIER
TH_BUILDING_01

END_OF_DOCUMENT

Field Log

Issue 01 // 2026

WED, JAN 14, 2026Available now
Observations on the intersection of design engineering, human-computer interaction, and the building of tools.

Selected Entries

Loading posts…
Fin.
BT Norris // 2026