Schede

Questo pattern mostra come creare un componente della scheda con griglia e punti di aggancio dello scorrimento.

Articolo completo · Video su YouTube · Fonte su 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>

        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;
   
}
 
}
}
       

       
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
()
}