Skip to content

Add arrow navigation to all carousels#17956

Open
nloureiro wants to merge 4 commits intodevfrom
fix/carousel-arrows
Open

Add arrow navigation to all carousels#17956
nloureiro wants to merge 4 commits intodevfrom
fix/carousel-arrows

Conversation

@nloureiro
Copy link
Copy Markdown
Contributor

@nloureiro nloureiro commented Apr 10, 2026

Summary

  • Enhances EdgeScrollContainer with built-in prev/next arrow buttons and per-item pagination dots
  • Migrates all simple card carousels from Swiper.js to the unified EdgeScrollContainer
  • Navigation auto-hides when all content fits on screen

Fixes #17945

Design

  • Arrows: Purple chevrons, no border by default. On hover: light grey border + purple shadow (matches site button system)
  • Dots: 8px, one per item. Active = filled purple, inactive = light outlined circle
  • RTL-aware via existing ChevronPrev/ChevronNext components

Pages affected

Carousels NOT migrated (specialized Swiper features)

  • BentoCardSwiper — card stacking effect
  • SavingsCarousel — slide change animation callbacks
  • StartWithEthereumFlow — programmatic slide control
  • BuilderSwiper — fractional slidesPerView
  • TorchHistorySwiper — coverflow 3D effect
  • ScreenshotSwiper — dual carousel with programmatic control
  • TopApps / WhySwiper — analytics slide change callbacks
  • ReleaseCarousel — dual synced Embla carousels

Storybook

See Molecules > Navigation > EdgeScrollContainer for all stories (Basic, Few Items, Without Snap, As Child Example).

Test plan

  • Verify arrows and dots appear on all affected pages when content overflows
  • Verify navigation hides when all items fit (e.g. few hackathons)
  • Click arrows — scrolls to next/prev item, dots update
  • Click dots — scrolls to that item
  • Check RTL (Arabic) — arrows flip correctly
  • Check mobile — carousels still swipeable
  • Verify Swiper-based carousels (BentoCard, Savings, etc.) still work unchanged

Enhance EdgeScrollContainer with built-in prev/next arrow buttons and
per-item pagination dots. Navigation auto-hides when content fits
without scrolling. Migrate all simple card carousels from Swiper.js
to the unified EdgeScrollContainer component.

Fixes #17945
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 10, 2026

Deploy Preview for ethereumorg ready!

Name Link
🔨 Latest commit 1e5d49a
🔍 Latest deploy log https://app.netlify.com/projects/ethereumorg/deploys/69e38a532dc70c000866663e
😎 Deploy Preview https://deploy-preview-17956.ethereum.it
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
7 paths audited
Performance: 56 (🔴 down 3 from production)
Accessibility: 94 (no change from production)
Best Practices: 100 (no change from production)
SEO: 98 (🔴 down 1 from production)
PWA: 59 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

Rename the component and file to a standard, recognizable name:
- EdgeScrollContainer → Carousel
- EdgeScrollItem → CarouselItem
- edge-scroll-container.tsx → carousel.tsx
- Embla carousel → embla-carousel.tsx (used only by ReleaseCarousel)
@konopkja
Copy link
Copy Markdown
Contributor

@nloureiro id suggest for all cards to also have ability to drag on laptop, even with a mouse

@nloureiro
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 13, 2026

Claude finished @nloureiro's task in 3m 30s —— View job


Code Review: Add arrow navigation to all carousels

Tasks:

  • Understand the request
  • Read core carousel component (carousel.tsx)
  • Read new embla carousel component (embla-carousel.tsx)
  • Review migrated components
  • Check accessibility and RTL support
  • Post review findings

Overall this is a well-structured PR that cleanly unifies carousel behaviour. The migration pattern is consistent across all affected pages, and the auto-hide logic when content fits is a nice touch. A few issues worth addressing before merge are below.


Bugs

1. Event listener leak in embla-carousel.tsx (line 119–122)

The reInit event is registered but never cleaned up:

api.on("reInit", onSelect)  // added
api.on("select", onSelect)  // added

return () => {
  api?.off("select", onSelect)  // only select is removed — reInit leaks
}

ReleaseCarousel is a complex component that mounts/unmounts on route changes. Fix:

return () => {
  api?.off("reInit", onSelect)
  api?.off("select", onSelect)
}

Fix this →


2. RTL active-dot detection is broken (carousel.tsx, lines 110–120)

Active index is found by measuring distance from the container's left edge:

const containerLeft = el.getBoundingClientRect().left
items.forEach((item, i) => {
  const dist = Math.abs(item.getBoundingClientRect().left - containerLeft)
  ...
})

In RTL, the start edge is on the right side, so the wrong dot will be highlighted when scrolling. This doesn't affect arrow flipping (handled correctly via ChevronPrev/ChevronNext), only the dot indicator.

Fix: use inline-start side based on writing direction:

const isRtl = getComputedStyle(el).direction === "rtl"
const containerEdge = isRtl
  ? el.getBoundingClientRect().right
  : el.getBoundingClientRect().left

items.forEach((item, i) => {
  const itemEdge = isRtl
    ? item.getBoundingClientRect().right
    : item.getBoundingClientRect().left
  const dist = Math.abs(itemEdge - containerEdge)
  ...
})

Fix this →


3. Dead outerRef (carousel.tsx, lines 73, 176–181)

outerRef is created and assigned, but never read or passed anywhere. The ref-merge callback is unnecessary complexity:

const outerRef = React.useRef<HTMLDivElement>(null)   // created but never used
...
ref={(node) => {
  ;(outerRef as React.MutableRefObject<HTMLDivElement | null>).current = node
  if (typeof ref === "function") ref(node)
  else if (ref) ref.current = node
}}

Can be simplified — just forward the ref directly and remove outerRef:

<div ref={ref} className={cn("space-y-6", className)}>

Fix this →


Accessibility

4. Missing keyboard navigation (carousel.tsx)

embla-carousel.tsx has keyboard arrow-key support via handleKeyDown, but carousel.tsx has none. Users who navigate by keyboard cannot move through the carousel.

Suggest adding:

<div
  className={cn("space-y-6", className)}
  onKeyDown={(e) => {
    if (e.key === "ArrowLeft") { e.preventDefault(); scrollByPage("start") }
    if (e.key === "ArrowRight") { e.preventDefault(); scrollByPage("end") }
  }}
  tabIndex={0}
>

5. Missing role="region" / aria-roledescription (carousel.tsx)

embla-carousel.tsx correctly marks the container as a carousel region:

role="region"
aria-roledescription="carousel"

carousel.tsx has no equivalent, so screen readers will not announce the widget type to users. Add these to the outer div.


6. aria-current vs aria-pressed on pagination dots (carousel.tsx, line 215)

aria-current is for navigation contexts (breadcrumbs, step indicators, paginated links). A toggle-style button should use aria-pressed:

// Current:
aria-current={i === activeIndex ? "true" : undefined}

// Better:
aria-pressed={i === activeIndex}

7. Non-specific aria-label on arrow buttons (carousel.tsx, lines 201, 229)

aria-label="Previous" and aria-label="Next" are too generic. If a page has multiple carousels, a screen reader user can't tell them apart. Consider accepting an aria-label prop on Carousel and forwarding it to the <nav>:

<nav ?? "Carousel navigation"}>

Architecture

8. Naming conflict between carousel.tsx and embla-carousel.tsx

Both files export Carousel and CarouselItem with incompatible APIs. embla-carousel.tsx is consumed only by ReleaseCarousel.tsx. If a developer imports from the wrong file they get silent runtime differences.

embla-carousel.tsx already has // TODO: Fix RTL compatibility at the top — naming it something like EmblaCarousel internally, or at least adding a comment warning about the naming collision, would reduce confusion.


Minor

9. key={index} in InnovationSwiper and VideoCourseSwiper

InnovationSwiper/index.tsx:17 and VideoCourseSwiper/index.tsx:15 use array index as React key. For static lists this has no runtime impact, but it's an established anti-pattern. Prefer stable identifiers like title or course.id.

10. w-[100%] in InnovationSwiper/index.tsx line 12

w-[100%] is identical to w-full. Minor.

11. global.css import order (positive!)

Moving @import statements before @tailwind directives is the correct CSS spec order — good fix.


Summary

Count
Bugs 3
Accessibility gaps 4
Architecture concerns 1
Minor / nitpicks 3

The core logic is solid. The most important items to address before merge are the event listener leak (#1), keyboard navigation (#4), and missing ARIA role (#5).

Bug fixes:
- embla-carousel: clean up reInit event listener on unmount
- carousel: fix active-dot detection in RTL (use inline-start edge)
- carousel: remove dead outerRef and simplify ref forwarding

Accessibility:
- carousel: add role=region + aria-roledescription=carousel
- carousel: add keyboard navigation (ArrowLeft/Right) with tabIndex
- carousel: replace aria-current with aria-pressed on pagination dots
- carousel: accept aria-label prop and forward to nav for disambiguation

Minor:
- InnovationSwiper/VideoCourseSwiper: use stable title as React key
- InnovationSwiper: w-[100%] → w-full
@nloureiro nloureiro marked this pull request as ready for review April 17, 2026 11:39
Users with a mouse (no trackpad) can now click and drag to scroll
through carousel items. Shows grab cursor on hover, grabbing cursor
while dragging. Snap scrolling temporarily disabled during drag for
smooth movement.

Addresses feedback from @konopkja on PR #17956.
Copy link
Copy Markdown
Member

@pettinarip pettinarip left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nloureiro thanks for taking this on! is a real win for the codebase :)

A couple of things I noticed while testing the preview:

  • RTL is broken. On /ar/ and /ur/ the dots and arrows navigate in the wrong direction — clicking "next" jumps backwards, the Previous button is enabled at the visual start, etc. This affects all 8 migrated carousels.
  • Dots don't reach the last position when the end is already visible. Screenshot below — the Next arrow is disabled (correctly, last card is in view) but the active dot is stuck at position 3 of 6 instead of advancing to 6.
Image

Beyond those specific bugs, the bigger question I have is architectural: why a full rewrite instead of building on top of the shadcn Carousel we already have?

The embla-carousel.tsx this PR creates is literally shadcn's Carousel. Embla has RTL solved natively, handles the scroll-handler perf concerns for free, and ships battle-tested keyboard/touch/focus behavior.

I think we'd get further by renaming embla-carousel.tsx back to carousel.tsx as the canonical primitive and layering our edge-to-edge bleed + auto-hiding nav rather than maintaining a parallel scroll-snap implementation alongside it.

@wackerow
Copy link
Copy Markdown
Member

@konopkja

@nloureiro id suggest for all cards to also have ability to drag on laptop, even with a mouse

Just my opinion but I find this behavior fairly disruptive on desktop. Attempting to select text produces click-and-drag behavior which cannot be bypassed. Currently this also produces a "click" on mouse-up when click-dragging over a link (slide to navigate, but then the link is simultaneously followed).

Is the desire here for when the user doesn't have easy access to horizontal scrolling (e.g., mouse wheel with only on direction)?


image

This hover effect is brand new to the repo as well, not used anywhere else.. Feels a bit off and adds quite a lot of space between the arrow and dots when not hovering

@nloureiro
Copy link
Copy Markdown
Contributor Author

@nloureiro thanks for taking this on! is a real win for the codebase :)

A couple of things I noticed while testing the preview:

  • RTL is broken. On /ar/ and /ur/ the dots and arrows navigate in the wrong direction — clicking "next" jumps backwards, the Previous button is enabled at the visual start, etc. This affects all 8 migrated carousels.
  • Dots don't reach the last position when the end is already visible. Screenshot below — the Next arrow is disabled (correctly, last card is in view) but the active dot is stuck at position 3 of 6 instead of advancing to 6.
Image Beyond those specific bugs, the bigger question I have is architectural: why a full rewrite instead of building on top of the shadcn Carousel we already have?

The embla-carousel.tsx this PR creates is literally shadcn's Carousel. Embla has RTL solved natively, handles the scroll-handler perf concerns for free, and ships battle-tested keyboard/touch/focus behavior.

I think we'd get further by renaming embla-carousel.tsx back to carousel.tsx as the canonical primitive and layering our edge-to-edge bleed + auto-hiding nav rather than maintaining a parallel scroll-snap implementation alongside it.

Not sure if I follow all details of your comment but I will try to look into it

@nloureiro
Copy link
Copy Markdown
Contributor Author

@konopkja

@nloureiro id suggest for all cards to also have ability to drag on laptop, even with a mouse

Just my opinion but I find this behavior fairly disruptive on desktop. Attempting to select text produces click-and-drag behavior which cannot be bypassed. Currently this also produces a "click" on mouse-up when click-dragging over a link (slide to navigate, but then the link is simultaneously followed).

Is the desire here for when the user doesn't have easy access to horizontal scrolling (e.g., mouse wheel with only on direction)?

image This hover effect is brand new to the repo as well, not used anywhere else.. Feels a bit off and adds quite a lot of space between the arrow and dots when not hovering

I will review the behavior. Thank you for raising it.

The hover shadow is the same as we used on buttons, no?
this the hover on the new homepage
Screen Shot 2026-04-23 09 03 51 AM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add arrow on all the carousels

4 participants