Skip to content

Add arrow navigation to all carousels#17956

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

Add arrow navigation to all carousels#17956
nloureiro wants to merge 3 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 36bd501
🔍 Latest deploy log https://app.netlify.com/projects/ethereumorg/deploys/69dcf2dfd5d524000880572c
😎 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: 57 (🟢 up 1 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
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

2 participants