이 패턴은 그리드 및 스크롤 맞추기 지점을 사용하여 탭 구성요소를 만드는 방법을 보여줍니다.
전체 기사 · YouTube 동영상 · 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()
}