Tabs

This pattern shows how to create a tab component with grid and scroll snap points.

Full article · Video on YouTube · Source on Github

HTML

<snap-tabs>
  <header class="scroll-snap-x"> 
    <nav>
      <a active href="#responsive">Responsive</a>
      <a href="#accessible">Accessible</a>
      <a href="#overscroll">Horizontal Overscroll Ready</a>
      <a href="#more"><!-- ...SVG icon --></a>
    </nav>
    <span class="snap-indicator"></span>
  </header>
  <section class="scroll-snap-x">
    <article id="responsive">
      <!-- ...content -->
    </article>
    <article id="accessible">
      <!-- ...content -->
    </article>
    <article id="overscroll">
      <!-- ...content -->
    </article>
    <article id="more">
      <!-- ...content -->
    </article>
  </section>
</snap-tabs>

CSS


        snap-tabs {
  --hue: 328deg;
  --accent: var(--hue) 100% 54%;
  --indicator-size: 2px;

  --space-1: .5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  
  display: flex;
  flex-direction: column;

  overflow: hidden;
  position: relative;

  & :matches(header, nav, section, article, a) {
    outline-color: hsl(var(--accent));
    outline-offset: -5px;
  }
}

.scroll-snap-x {
  overflow: auto hidden;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;

  @media (prefers-reduced-motion: no-preference) {
    scroll-behavior: smooth;
  }
  
  @media (hover: none) {
    scrollbar-width: none;

    &::-webkit-scrollbar {
      width: 0;
      height: 0;
    }
  }
}

snap-tabs > header {
  --text-color: hsl(var(--hue) 5% 40%);
  --text-active-color: hsl(var(--hue) 20% 10%);

  flex-shrink: 0;
  min-block-size: fit-content;

  display: flex;
  flex-direction: column;

  & > nav {
    display: flex;
  }

  & a {
    scroll-snap-align: start;

    display: inline-flex;
    align-items: center;
    white-space: nowrap;

    font-size: .8rem;
    color: var(--text-color);
    font-weight: bold;
    text-decoration: none;
    padding: var(--space-2) var(--space-3);

    & > svg {
      inline-size: 1.5em;
      pointer-events: none;
    }

    &:hover {
      background: hsl(var(--accent) / 5%);
    }

    &:focus {
      outline-offset: -.5ch;
    }
  }

  & > .snap-indicator {
    inline-size: 0;
    block-size: var(--indicator-size);
    border-radius: var(--indicator-size);
    background: hsl(var(--accent));
  }
}

snap-tabs > section {
  block-size: 100%;

  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;

  & > article {
    scroll-snap-align: start;
    overflow-y: auto;
    overscroll-behavior-y: contain;

    padding: var(--space-2) var(--space-3);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* 
    - swap to border-bottom styles
    - transition colors
    - hide the animated .indicator 
  */

  snap-tabs {
    & > header a {
      border-block-end: var(--indicator-size) solid hsl(var(--accent) / 0%);
      transition:
        color .7s ease,
        border-color .5s ease;

      &:matches(:target,:active,[active]) {
        color: var(--text-active-color);
        border-block-end-color: hsl(var(--accent));
      }
    }

    & .snap-indicator {
      visibility: hidden;
    }
  }
}
        

JS


        import 'https://argyleink.github.io/scroll-timeline/dist/scroll-timeline.js'

const {matches:motionOK} = window.matchMedia(
  '(prefers-reduced-motion: no-preference)'
)

// grab and stash elements
const tabgroup     = document.querySelector('snap-tabs')
const tabsection   = tabgroup.querySelector(':scope > section')
const tabnav       = tabgroup.querySelector(':scope nav')
const tabnavitems  = tabnav.querySelectorAll(':scope a')
const tabindicator = tabgroup.querySelector(':scope .snap-indicator')

/* 
  shared timeline for .indicator 
  and nav > a colors */
const sectionScrollTimeline = new ScrollTimeline({
  scrollSource: tabsection,
  orientation: 'inline',
  fill: 'both',
})

/*
  for each nav link
  - animate color based on the scroll timeline
  - color is active when it's the current index*/
tabnavitems.forEach(navitem => {
  navitem.animate({
      color: [...tabnavitems].map(item => 
        item === navitem
          ? `var(--text-active-color)`
          : `var(--text-color)`)
    }, {
      duration: 1000,
      fill:     'both',
      timeline: sectionScrollTimeline,
    }
  )
})

if (motionOK) {
  tabindicator.animate({ 
      transform: [...tabnavitems].map(({offsetLeft}) => 
        `translateX(${offsetLeft}px)`),
      width: [...tabnavitems].map(({offsetWidth}) => 
        `${offsetWidth}px`)
    }, {
      duration: 1000,
      fill:     'both',
      timeline: sectionScrollTimeline,
    }
  )
}

const setActiveTab = tabbtn => {
  tabnav
    .querySelector(':scope a[active]')
    .removeAttribute('active')
  
  tabbtn.setAttribute('active', '')
  tabbtn.scrollIntoView()
}
 
const determineActiveTabSection = () => {
  const i = tabsection.scrollLeft / tabsection.clientWidth
  const matchingNavItem = tabnavitems[i]
  
  matchingNavItem && setActiveTab(matchingNavItem)
}

tabnav.addEventListener('click', e => {
  if (e.target.nodeName !== "A") return
  setActiveTab(e.target)
})

tabsection.addEventListener('scroll', () => {
  clearTimeout(tabsection.scrollEndTimer)               
  tabsection.scrollEndTimer = setTimeout(
    determineActiveTabSection
  , 100)
})

window.onload = () => {
  if (location.hash)
    tabsection.scrollLeft = document
      .querySelector(location.hash)
      .offsetLeft
    
  determineActiveTabSection()
}