Skip to main content

September 23, 2026

Nested Embla: watchDrag and viewport gutters

On a Centra PDP, nested Embla carousels for card media and edge-bleed rails hit two traps. Use watchDrag on the parent; safe-area inset on the viewport.

Sander Korf4 min read

A Centra fashion storefront ships horizontal product and review rails as Embla carousels (Embla is the carousel library that translates a flex track inside an overflow-hidden viewport). Cards can host their own Embla for image assets. On mobile the same rails bleed to the screen edge while design still wants a small gutter at the safe area. Shoppers swipe the outer list to browse cards, swipe inside a card to flip looks, and expect the first card to stay inset from the notch after every snap.

I wired both patterns on the same night and hit two failures that look unrelated until you notice Embla moves one node and listens on another.

Horizontal swipes on card media moved the parent rail

On a PLP-style rail with multi-asset cards, vertical trackpad pans scrolled the page fine. Horizontal pans on a card media viewport dragged the outer list instead of the inner carousel. I added onPointerDownCapture on the nested root and called stopPropagation. The parent stopped stealing swipes. The nested Embla stopped receiving them too.

Embla attaches pointer and touch listeners on the carousel root. Capture-stopping those events before they reach the child means the nested instance never starts a drag. I had "fixed" theft by killing the feature I meant to protect.

Wheel is a separate channel. Parent lists that mount WheelGesturesPlugin still hear wheel unless the nested host stops bubbling. Nested media can call stopPropagation on wheel only. Pointer stays on Embla's handlers; the parent plugin does not advance slides while the pointer sits over card media.

The parent opts into an allow function via watchDrag. When the event target sits inside a marked nested host, the parent returns false and skips its drag.

function allowCarouselDragUnlessNested(_api: unknown, event: Event): boolean {
  const target = event.target
  if (!(target instanceof Element)) return true
  return target.closest('[data-carousel-nested]') === null
}

Nested carousel roots carry data-carousel-nested. No capture-stop on pointer or mouse. I also dropped forceWheelAxis: 'y' on horizontal rails. Without it, WheelGesturesPlugin follows Embla's axis: horizontal trackpad pans move slides, vertical pans scroll the page.

Safe-area padding on the track vanished after the first snap

Bleed rails used px-safe-or-* on the Embla track. First paint looked correct. After one swipe the padding rode with the translated flex row and the leading card kissed the physical edge. Prev and next still worked. The gutter did not.

Embla translates the track. Padding on that node is slide layout, not chrome for the clip region. Edge inset belongs on the overflow-hidden viewport Embla wraps, the node that stays fixed while the track slides underneath.

We pass it through viewportClassName on shared carousel content:

export const carouselEdgeInsetClassName = cn('px-safe-or-2 md:px-safe-or-4')

Product and review carousels set viewportClassName={carouselEdgeInsetClassName} and keep safe-area classes off the track className. Spacing between slides uses gap on the track instead of negative margin hacks on items.

Screenshot diffs that compare snap index 0 to index 1 catch the bug fast. QA that only stares at the first slide will miss it.

Try it in the demo

The interactive block below uses real Embla wiring. Toggle Broken vs Fixed at the top, then swipe horizontally on the middle card's image strip. In Broken mode the outer rail steals the gesture; in Fixed mode the inner carousel should move. In the gutter lab, snap to slide index 1 and watch the red safe-area guide: padding on the track disappears after the first snap until you move inset classes to the viewport.

Interactive

Nested Embla: watchDrag and viewport gutters

Miniature PLP rail with real Embla nested media. Drag the middle card looks, toggle Broken vs Fixed, then snap the parent rail and watch leading gap vs the safe inset band.

New arrivals

Bleed rail with safe inset

Stone wash
Natural flax
Collar detail
Looks

Relaxed linen jacket

€128

3 colours

Charcoal melange
Oatmeal rib
Fold detail
Looks

Fine merino crew

€89

3 colours

Wide leg drape

Pleated wide trouser

€110

Drag horizontally on the middle card photo strip. Use snap buttons to compare gutter modes at index 0 vs 1.

Broken: no watchDrag gateParent snap: 0Nested snap: 0Gutter on trackLeading gap: 16px
Demo resultSwipe the middle card media horizontally, then toggle Broken vs Fixed. Red band marks the safe inset; leading gap is measured live.

Same rails, two rules

Nested drag isolation and viewport inset do not depend on each other, but they show up together on bleed product rails. Mark nested roots, gate the parent with watchDrag, stop wheel bubbling only. Put bleed gutters on the viewport, gaps on the track. Padding every CarouselItem to recover a missing inset double-counts once gap is correct.

If a design review says padding disappears after swipe, inspect which node carries the class before you touch snap or align options. If horizontal swipes on nested media still move the parent, look for capture-stop on the child before you tweak axis plugins.

Happy coding!

Sander