A Centra fashion storefront ships horizontal product and review rails as Embla carousels. 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.
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