Skip to content
Learn Measure Blog Case studies About
  • Home
  • All patterns
  • Component patterns

Tabs

Jun 1, 2022

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

Full article · Video on YouTube · Source on Github

<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>
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 its 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()
}
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;
    }
  }
}
Open demo
Last updated: Jun 1, 2022 — Improve article
Share
subscribe

Contribute

  • File a bug
  • View source

Related content

  • developer.chrome.com
  • Chrome updates
  • Web Fundamentals
  • Case studies
  • Podcasts
  • Shows

Connect

  • Twitter
  • YouTube
  • Google Developers
  • Chrome
  • Firebase
  • Google Cloud Platform
  • All products
  • Terms & Privacy
  • Community Guidelines

Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies.