Skip to main content

Horizontal Free-Scroller v1.5.0

Enables horizontal scrolling of content within a limited width, serving as a sleek alternative to carousels.

  • Item 1

    Lorem ipsum dolor sit amet, consectetur ultricies, ante erat imperdiet velit, nec laoreet enim lacus a velit.

  • Item 2

    Nam euismod, tortor nec pharetra ultricies, ante erat imperdiet velit, nec laoreet enim lacus.

  • Item 3

    Ipsum dolor sit amet, consectetur adipiscing elit. Nam euismod, tortor nec pharetra ultricies, ante erat imperdiet.

  • Item 4

    Dolor sit amet, consectetur adipiscing elit. Nam euismod, tortor nec pharetra ultricies, ante erat.

  • Item 5

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam euismod, tortor nec pharetra ultricies,

  • Item 6

    Tortor nec pharetra ultricies, ante erat imperdiet velit, nec laoreet enim lacus a velit.

  • Item 7

    Ante erat imperdiet velit, nec laoreet enim lacus a velit.

  • Item 8

    Nam euismod, tortor nec pharetra ultricies, ante erat imperdiet velit, nec laoreet enim lacus a velit.

Note: All markup examples include aria-label, role, and tabindex attributes. These are required when the content is generated by a CMS or when it's unclear if interactive elements will be present. If interactive elements are detected, the script will dynamically remove these attributes. The script is optional—if you're certain the component will always include interactive content, you can omit these attributes and take a hardcoded approach instead.

Scrollbars & Accessibility Anchor (Scrollbars & Accessibility)

Scrollbars can look and work differently depending on the operating system and browser, which is normal. If you choose to style scrollbars, keep in mind that not all browsers support the same options, so the look and feel may change. For example, Safari may hide scrollbars completely, which is expected. For accessibility, ensure the following:

  • The scrubber, track, and background must each have a 3:1 contrast ratio with one another. For example, the scrubber must have a 3:1 contrast with the track, and the track must have a 3:1 contrast with the background beneath it. In some cases, it may be advisable to have the scrubber meet a 4.5:1 ratio with the track, for more solid usability.
  • The hover state of the scrubber must meet a 3:1 contrast ratio with the track.
  • The scrollbar must always include a 24px by 24px target area size. This isn't an issue for horizontal spacing on horizontal scrollbars, but be mindful of vertical spacing—if the scrollbar is too close to other interactive elements above or below it, it may be harder to use and will fail Target Size (Minimum) (Level AA). The opposite would be true on vertial scrollbars.
  • There are no recommended dimensions for scrollbar scrubber height (horizontal) or width (vertical), but larger sizes would obviously be more noticeable and easier to use.
  • While customizing scrollbars is permitted it is not generally recommended. Using native scrollbars is usually the most usable and accessible option. See Don't use custom CSS scrollbars for more details on why.
An example of a scrollbar with a 24px by 24px target area. Note how there are no interactive elements, other than the scrollbar, within this range.
<div class="horizontal-scroll" aria-label="Example Name" role="region" tabindex="0">

    <ul role="list">

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 1</a></h2>

            <p>Lorem ipsum dolor sit amet, consectetur ultricies, ante erat imperdiet velit, nec laoreet enim lacus a velit.</p>

        </li>

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 2</a></h2>

            <p>Nam euismod, tortor nec pharetra ultricies, ante erat imperdiet velit, nec laoreet enim lacus.</p>

        </li>

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 3</a></h2>

            <p>Ipsum dolor sit amet, consectetur adipiscing elit. Nam euismod, tortor nec pharetra ultricies, ante erat imperdiet.</p>

        </li>

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 4</a></h2>

            <p>Dolor sit amet, consectetur adipiscing elit. Nam euismod, tortor nec pharetra ultricies, ante erat.</p>

        </li>

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 5</a></h2>

            <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam euismod, tortor nec pharetra ultricies,</p>

        </li>

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 6</a></h2>

            <p>Tortor nec pharetra ultricies, ante erat imperdiet velit, nec laoreet enim lacus a velit.</p>

        </li>

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 7</a></h2>

            <p>Ante erat imperdiet velit, nec laoreet enim lacus a velit.</p>

        </li>

        <li class="horizontal-scroll__card">

            <h2 class="horizontal-scroll__heading"><a href="#!">Item 8</a></h2>

            <p>Nam euismod, tortor nec pharetra ultricies, ante erat imperdiet velit, nec laoreet enim lacus a velit.</p>

        </li>

    </ul>

</div>
/*!

  Radancy Component Library: Horizontal Scroll

  Contributor(s):
  Michael "Spell" Spellacy

  Dependencies: Sass

*/

.horizontal-scroll {

// Component Variables

--scroll-color-track: #949595; // Track Color
--scroll-color-thumb: #5D00C7; // Thumb Color
--scroll-color-active: #3E0085; // Active State

display: grid;
gap: 1em;
grid-auto-flow: column;
overflow-x: auto;
padding-block-end: 1em;
scroll-snap-type: inline mandatory;

// Fallback Scrollbar

scrollbar-color: var(--scroll-color-thumb) var(--scroll-color-track);
scrollbar-width: thin;

  // TODO: Add native scrollbar hover: https://nerdy.dev/notebook/custom-scrollbar.html 

  @supports #{"\selector(::-webkit-scrollbar)"} {

    scrollbar-color: initial;
    scrollbar-width: auto;

    &::-webkit-scrollbar {
    height: calc(8rem / 16);
    }

    &::-webkit-scrollbar-track, &::-webkit-scrollbar-thumb {
    border-radius: 100vw;
    }

    &::-webkit-scrollbar-track {
    background-color: var(--scroll-color-track);
    }

    &::-webkit-scrollbar-thumb {
    background-color: var(--scroll-color-thumb);
    
      &:hover {
      background-color: var(--scroll-color-active);
      }

    }
  
  }

  &:not([data-always-scroll]) { 

    @media (min-width: 62.5em) {

      grid-auto-flow: unset;
      grid-template-columns: repeat(4, 1fr);
      visibility: hidden; // Do not remove.

      .horizontal-scroll__card {

        min-width: auto;

      }

    }
  
  }

  > ul, > ol {
  display: contents;
  list-style: none;
  visibility: visible; // Do not remove.
  }

  // .horizontal-scroll__card

  &__card {
  background-color: #fff;
  border-radius: calc(10rem / 16);
  border: calc(1rem / 16) #666 solid;
  min-width: calc(300rem / 16);
  padding: 1em;
  position: relative;
  scroll-snap-align: center;
  transition: 300ms;

    &:has(a, button) {

      &:hover, &:focus-within {
      background-color: #eee;
      border-color: #6F00EF;
      }

    }

  }

  // .horizontal-scroll__heading

  &__heading {

    a {
    display: block;

      // Make entire card clickable

      &::before {
      content: "";
      display: block;
      inset: 0;
      position: absolute;
      }

    }

  }

}
/*!

  Radancy Component Library: Horizontal Free-Scroller

  Contributor(s):
  Michael "Spell" Spellacy
  Connor Baba

*/

(() => {

  "use strict";

  // Display which version is in use via console:

  console.log("%cHorizontal Free-Scroller%cv1.5.0", "background: #2d2d2d; color: #fff; padding: 6px 10px; border-radius: 16px 0 0 16px; font-weight: 600;" , "background: #6e00ee; color: #fff; padding: 6px 10px; border-radius: 0 16px 16px 0; font-weight: 600;");

  // Classes, data attributes, states, and strings.

  const scrollerClass = ".horizontal-scroll";
  const scrollers = document.querySelectorAll(scrollerClass);

  scrollers.forEach((scroll) => {

    // CMS Editability Modification

    const scrollerAriaElement = scroll.querySelector('[data-aria-label-replace]');
    const scrollerAriaLabel = scrollerAriaElement?.getAttribute("data-aria-label-replace") ?? "";
  
    // Checks if label exists, and then replaces it with CMS value. When done, deletes junk element from DOM.

    if(scrollerAriaLabel) {

      scroll.setAttribute("aria-label", scrollerAriaLabel);
      scrollerAriaElement.remove();

    }
    
    var hasInteractiveElements = scroll.querySelectorAll("a, button, video, [tabindex='0'], [data-href*='facebook/videos'], iframe[src*='vimeo.com'], iframe[src*='youtube.com']");

    // See if interactive elements exist. If they do, remove aria-label, role, and tabindex.

    if (hasInteractiveElements.length) {

      ["aria-label", "role", "tabindex"].forEach(attr => scroll.removeAttribute(attr));

    }

    // Mousewheel Support

    const isRTL = document.dir === "rtl" || getComputedStyle(scroll).direction === "rtl"; // RTL Support

    scroll.addEventListener("wheel", (event) => {
    
      const canScroll = scroll.scrollWidth > scroll.clientWidth;
    
      if (!canScroll) return; // nothing to scroll horizontally

      const isAtStart = scroll.scrollLeft === 0;
      const isAtEnd = scroll.scrollLeft + scroll.clientWidth >= scroll.scrollWidth;
      const scrollDelta = isRTL ? -event.deltaY : event.deltaY;

      // Only prevent default if there is actually room to scroll in that direction
    
      const shouldScroll = (scrollDelta < 0 && !isAtStart) || (scrollDelta > 0 && !isAtEnd);
    
      if (!shouldScroll) return; // allow normal vertical scroll

      event.preventDefault();
    
      scroll.scrollBy({ left: scrollDelta, behavior: "smooth" });
    
    }, { 
      
      passive: false 
    
    }); 

  });

})();

Properties Anchor (Properties)

Use the following properties to configure this component.

Attribute Applies To Note Description
aria-label .horizontal-scroll String, Mandatory
All scrollers must be given a unique accessible name, which relates to group of items within. The script will decide if this attribute is required based on the presence of interactive elements within component and it is used as a fallback to provide an accessible name if script fails. May be omitted for hardcoded scrollers where interactive elements will be known to always exist.
role .horizontal-scroll String, Mandatory
All scrollers must be given a role with a value of "region". The script will decide if this attribute is required based on the presence of interactive elements within component and it is used as a fallback to identify the region if script fails. May be omitted for hardcoded scrollers where interactive elements will be known to always exist.
tabindex .horizontal-scroll Number, Mandatory
All scrollers must be given a tabindex with a value "0". The script will decide if this attribute is required based on the presence of interactive elements within component and it is used as a fallback to scroll if script fails. May be omitted for hardcoded scrollers where interactive elements will be known to always exist.
data-always-scroll .horizontal-scroll Boolean, Optional
By default, scolling is present on smaller screens but not on larger ones. This is used as hook in the CSS to bypass the default behavior, allowing a scroll to be present across all viewport sizes.
data-aria-label-replace .horizontal-scroll__card String, Optional
Can be used in specific templating scenarios where updating the default aria-label value is not possible. Additionally, use the hidden attribute to hide this list item.

Class Applies To Note Description
.horizontal-scroll div, section Mandatory
Used on the parent element that wraps your content.
.horizontal-scroll__card article, div, li, section Recommended
Used for each individual content container. Recommended, but if custom CSS is desired you may create your own.
.horizontal-scroll__heading h2, h3, h4, h5, h6 Recommended
Used for each individual content heading. Ideally, you'll want each heading to contain your hyperlink, so that the purpose of each link can be better understood by assistive technologies. Recommended, but if custom HTML and CSS is desired you may create your own.

Release Notes Anchor (Release Notes)

Keep up to date on major changes to this component.

Behavior
January 28, 2026: Integrated mouse wheel support. Version now 1.5.
Behavior
September 24, 2025: Added experimental support for mouse wheel scrolling. Version now 1.4.
Behavior
March 11, 2025: Added Connor's new functionality to pass a dynamic label to aria-label due to limitations within the CMS.

Behavior
If the script fails to load, the component will still be operable via keyboard, though there will be a double tabstop in cases where interactive elements like a and button are present.

Report Issues

Find a bug? Want a new feature? Report it on JIRA. You're so awesome!