What is it

A tabs component that provides different sections of content that are displayed one at a time when the user selects that information.

When to use it

The tabbed user interface enables users to jump to their target section quickly. Tabs present like logically group information on the same page. Information should

Users should not need to see content of multiple tabs simultaneously and the user should be able to easily recognise where they are within the content.

How to use it

The structure of the tab set is defined in html. There are two forms supported. Adding a class of .tab-group to the container element will work in place of the tabset tag, and the tab panels can be defined using either tab="" or data-tab="". Passing an optional element to the init function will initialise tabs within that element. Adding a order="" or data-order="" element to the tabset you can have the tabs sorted in a consistent order across tabsets.

Example

tabset(order="tab2, tab1")
	div(tab="tab1")
	div(tab="tab2")
        
          
tabset#uniqueID(order="tab title 2,tab title 1")
	div(tab="[tab title 1]")
	div(tab="[tab title 2]")
		
tabset, .tab-group {
	margin: 2rem 0 1rem 0;
}
tabset [role=tablist] li.selected, .tab-group [role=tablist] li.selected {
	background-color: var(--colour-green);
	color: var(--colour-white);
}
tabset > ul,
tabset .tab-scroll > ul, .tab-group > ul,
.tab-group .tab-scroll > ul {
	display: -webkit-box;
	display: -ms-flexbox;
	display: flex;
	margin: 0;
	padding: 0;
	-webkit-user-select: none;
	   -moz-user-select: none;
	    -ms-user-select: none;
	        user-select: none;
}
tabset > ul li.separator,
tabset .tab-scroll > ul li.separator, .tab-group > ul li.separator,
.tab-group .tab-scroll > ul li.separator {
	border-bottom: 1px solid #7f7f7f;
	border-left: 1px solid #7f7f7f;
	display: inline-block;
	-webkit-box-flex: 1;
	    -ms-flex: 1 1 auto;
	        flex: 1 1 auto;
	margin: 0.45rem 0 0 0;
	width: auto;
}
tabset .tab-hidden, .tab-group .tab-hidden {
	display: none;
}
tabset [role=tab], .tab-group [role=tab] {
	background-color: #FFF;
	border-left: 1px solid #7f7f7f;
	border-radius: 0.5rem 0.5rem 0 0;
	border-top: 1px solid #7f7f7f;
	cursor: pointer;
	display: block;
	-webkit-box-flex: 0;
	    -ms-flex: 0 0 auto;
	        flex: 0 0 auto;
	margin: 0;
	max-width: 100vw;
	overflow: hidden;
	padding: 1rem 1.5rem 0.14rem 1.5rem;
	-webkit-user-select: none;
	   -moz-user-select: none;
	    -ms-user-select: none;
	        user-select: none;
	white-space: normal;
	word-break: break-word;
}
tabset [role=tab]:last-of-type, .tab-group [role=tab]:last-of-type {
	border-right: 1px solid #7f7f7f;
}
tabset [role=tab]:not(.selected), .tab-group [role=tab]:not(.selected) {
	background-color: #f0f0f0;
	border-bottom: 1px solid #7f7f7f;
}
tabset [role=tab] span, .tab-group [role=tab] span {
	display: block;
	margin: 0 0 0.5rem 0;
}
tabset [role=tabpanel], .tab-group [role=tabpanel] {
	background-color: #FFF;
	border: 1px solid #7f7f7f;
	border-top: none;
	padding: 1rem;
	z-index: 1;
}
tabset [role=tabpanel]:not(.open), .tab-group [role=tabpanel]:not(.open) {
	display: none;
}

.tab-scroll {
	width: 100%;
	overflow-x: auto;
	overflow-y: hidden;
	scrollbar-width: none;
	-ms-overflow-style: none;
	-webkit-user-select: none;
	   -moz-user-select: none;
	    -ms-user-select: none;
	        user-select: none;
}
.tab-scroll::-webkit-scrollbar {
	display: none;
}
.tab-scroll > ul {
	-ms-flex-wrap: nowrap;
	    flex-wrap: nowrap;
	min-width: 100%;
	width: -webkit-max-content;
	width: -moz-max-content;
	width: max-content;
}

.tab-scroll-wrapper {
	position: relative;
	-webkit-user-select: none;
	   -moz-user-select: none;
	    -ms-user-select: none;
	        user-select: none;
}
.tab-scroll-wrapper::before, .tab-scroll-wrapper::after {
	content: "";
	position: absolute;
	top: 0;
	width: 2.5rem;
	height: 100%;
	pointer-events: none;
	z-index: 2;
	opacity: 0;
	-webkit-transition: opacity 0.2s ease;
	transition: opacity 0.2s ease;
}
.tab-scroll-wrapper::before {
	background: radial-gradient(circle at left center, rgb(255, 255, 255) 0%, rgba(255, 255, 255, 0.9) 50%, rgba(255, 255, 255, 0.5) 75%, rgba(255, 255, 255, 0.25) 95%, transparent 100%);
	border-top-right-radius: 2.5rem;
	border-bottom-right-radius: 2.5rem;
	left: 0;
}
.tab-scroll-wrapper::after {
	right: 0;
	background: radial-gradient(circle at right center, rgb(255, 255, 255) 0%, rgba(255, 255, 255, 0.9) 50%, rgba(255, 255, 255, 0.5) 75%, rgba(255, 255, 255, 0.25) 95%, transparent 100%);
	border-top-left-radius: 2.5rem;
	border-bottom-left-radius: 2.5rem;
}
.tab-scroll-wrapper.is-overflowing::before, .tab-scroll-wrapper.is-overflowing::after {
	opacity: 0;
	-webkit-transition: opacity 0.15s ease-out;
	transition: opacity 0.15s ease-out;
	-webkit-transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1);
	transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-scroll-wrapper.is-overflowing.show-left::before {
	opacity: 1;
}
.tab-scroll-wrapper.is-overflowing.show-right::after {
	opacity: 1;
}
.tab-scroll-wrapper .tab-scroll-arrow {
	-webkit-box-align: center;
	    -ms-flex-align: center;
	        align-items: center;
	cursor: pointer;
	display: none;
	height: 100%;
	-webkit-box-pack: center;
	    -ms-flex-pack: center;
	        justify-content: center;
	position: absolute;
	top: 0;
	-webkit-user-select: none;
	   -moz-user-select: none;
	    -ms-user-select: none;
	        user-select: none;
	width: 2.5rem;
	z-index: 3;
}
.tab-scroll-wrapper .tab-scroll-arrow path {
	fill: #7f7f7f;
}
.tab-scroll-wrapper .tab-scroll-arrow.left {
	left: 0;
}
.tab-scroll-wrapper .tab-scroll-arrow.right {
	right: 0;
}
.tab-scroll-wrapper .tab-scroll-arrow.hidden {
	display: none !important;
	pointer-events: none;
	visibility: hidden;
}
.tab-scroll-wrapper.is-overflowing .tab-scroll-arrow {
	display: -webkit-box;
	display: -ms-flexbox;
	display: flex;
}

Example

@use "scss/core/tabs/_tabs";
@include tabs{ 
	// optional content block
};
$tab-border: #7f7f7f !default;

$tab-selected: #FFF !default;
$tab-selected-text: #000 !default;

$tab-notselected: #f0f0f0 !default;
$tab-notselected-text: #000 !default;

@mixin tabs {

	tabset, .tab-group {

		margin: 2rem 0 1rem 0;

		[role="tablist"] li.selected {
			background-color: var(--colour-green);
			color: var(--colour-white);
			
		}

		> ul,
		.tab-scroll > ul {
			display: flex;
			margin: 0;
			padding: 0;
			user-select: none;

			li.separator {
				border-bottom: 1px solid $tab-border;
				border-left: 1px solid $tab-border;
				display: inline-block;
				flex: 1 1 auto;
				margin: .45rem 0 0 0;
				width: auto;
			}
		}

		.tab-hidden {
			display: none;
		}

		[role="tab"] {
			background-color: $tab-selected;
			border-left: 1px solid $tab-border;
			border-radius: .5rem .5rem 0 0;
			border-top: 1px solid $tab-border;
			cursor: pointer;
			display: block;
			flex: 0 0 auto;
			margin: 0;
			max-width: 100vw;
			overflow: hidden;
			padding: 1rem 1.5rem .14rem 1.5rem;
			user-select: none;
			white-space: normal;
			word-break: break-word;

			&:last-of-type {
				border-right: 1px solid $tab-border;
			}

			&:not(.selected) {
				background-color: $tab-notselected;
				border-bottom: 1px solid $tab-border;
			}

			span {
				display: block;
				margin: 0 0 .5rem 0;
			}
		}

		[role="tabpanel"] {
			background-color: $tab-selected;
			border: 1px solid $tab-border;
			border-top: none;
			padding: 1rem;
			z-index: 1;

			&:not(.open) {
				display: none;
			}

			@content;
		}
	}

	// scroller
	.tab-scroll {
		width: 100%;
		overflow-x: auto;
		overflow-y: hidden;
		scrollbar-width: none;
		-ms-overflow-style: none;
		user-select: none;

		&::-webkit-scrollbar {
			display: none;
		}

		> ul {
			flex-wrap: nowrap;
			min-width: 100%;
			width: max-content;
		}
	}

	// wrapper (now owns the fade)
	.tab-scroll-wrapper {
		position: relative;
		user-select: none;
		$radial: 2.5rem;
		// edge fades (this is the fix)
		&::before,
		&::after {
			content: "";
			position: absolute;
			top: 0;
			width: 2.5rem;
			height: 100%;
			pointer-events: none;
			z-index: 2;
			opacity: 0; // hidden by default
			transition: opacity 0.2s ease;
		}

		// left fade
		&::before {
			background: radial-gradient(
				circle at left center,
				rgba(255,255,255,1) 0%,
				rgba(255,255,255,0.9) 50%,
				rgba(255,255,255,0.5) 75%,
				rgba(255,255,255,0.25) 95%,
				transparent 100%
			);
			border-top-right-radius: $radial;
			border-bottom-right-radius: $radial;
			left: 0;
		}

		// right fade
		&::after {
			right: 0;
				background: radial-gradient(
					circle at right center,
					rgba(255,255,255,1) 0%,
					rgba(255,255,255,0.9) 50%,
					rgba(255,255,255,0.5) 75%,
					rgba(255,255,255,0.25) 95%,
					transparent 100%
				);
				border-top-left-radius: $radial;
				border-bottom-left-radius: $radial;
		}

		// show fade only when overflowing
		&.is-overflowing {
			&::before,
			&::after {
				opacity: 0;
				transition: opacity 0.15s ease-out;
				transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1);
			}
			
			&.show-left {
				&::before {
					opacity: 1;
				}
			}
			&.show-right {
				&::after {
					opacity: 1;
				}
			}
			
		}

		// arrows
		.tab-scroll-arrow {
			align-items: center;
			cursor: pointer;
			display: none;
			height: 100%;
			justify-content: center;
			position: absolute;
			top: 0;
			user-select: none;
			width: 2.5rem;
			z-index: 3;
			path {
				fill: $tab-border;
			}
			&.left {
				left: 0;
			}

			&.right {
				right: 0;
			}

			&.hidden {
				display: none !important;
				pointer-events: none;
				visibility: hidden;
			}
		}

		// only show arrows when overflow
		&.is-overflowing {
			.tab-scroll-arrow {
				display: flex;
			}
		}
	}
}

Example

import * as tabs from "./js/core/tabs/_tabs.js";
tabs.init();
/* DS2 core (c) 2024-2026 Alexander McIlwraith
   Released under Creative Commons Attribution-ShareAlike 4.0 International
*/

// create a pure JS mouse click event
const click = new MouseEvent('click', {
	view: window,
	bubbles: false,
	cancelable: true
});

// wait for an element to appear in the DOM
const waitForElement = (selector) => {
	return new Promise(resolve => {
		if (document.querySelector(selector)) {
			return resolve(document.querySelector(selector));
		}
		const observer = new MutationObserver(() => {
			if (document.querySelector(selector)) {
				observer.disconnect();
				resolve(document.querySelector(selector));
			}
		});
		observer.observe(document.body, {
			childList: true,
			subtree: true
		});
	});
};

// Tab logic
const tabNamespace = "#tab:";

const chooseTab = (tab) => {
	const siblings = Array.from(
		tab.closest('[role="tablist"]').querySelectorAll('[role="tab"]')
	);
	siblings.forEach(sibling => sibling.classList.remove("selected"));
	tab.classList.add("selected");

	const tabGroup = tab.closest("tabset, .tab-group");
	const tabPanels = Array.from(
		tabGroup.querySelectorAll('[role="tabpanel"]')
	);
	
	tabPanels.forEach(panel => panel.classList.remove("open"));
	const tabPanelId = tab.getAttribute("id").replace("tab", "tab-panel");
	document.getElementById(tabPanelId).classList.add("open");
};

let pushStateCount = 0;
let tabsetCount = 0;

export function init(container = document, spacer = true) {
	container.querySelectorAll(".tab-group, tabset").forEach(tabGroup => {

		if (tabGroup.querySelector('[role="tablist"]') === null) {

			if (tabGroup.getAttribute("id") == null) {
				tabGroup.setAttribute("id", `tab-group-${tabsetCount++}`);
			}

			const tabgroup = tabGroup.getAttribute("id");
			let tablist = "";

			Array.from(tabGroup.children).forEach(child => {

				const isDetails = child.nodeName === "DETAILS";
				const tabLabel = isDetails ? child.querySelector("summary")?.innerHTML : child.getAttribute("tab") || child.getAttribute("data-tab");

				if (tabLabel !== null) {

					const tabID = tabLabel.replace(/\W+/g, "-").toLowerCase();
					let tabPanel;

					if (isDetails) {
						tabPanel = child;
						tabPanel.setAttribute("open", "");
					} else {
						tabPanel = document.createElement("div");
						tabPanel.appendChild(child.cloneNode(true));
					}

					tabPanel.id = `tab-panel-${tabgroup}-${tabID}`;
					tabPanel.className = tablist === "" ? "open" : "";
					tabPanel.setAttribute("role", "tabpanel");
					tabPanel.setAttribute("tabindex", "0");
					tabPanel.setAttribute(
						"aria-labelledby",
						`tab-${tabgroup}-${tabID}`
					);

					child.parentNode.replaceChild(tabPanel, child);

					tablist += ``;
				} else {
					child.classList.add("tab-hidden");
				}
			});

			const ul = document.createElement("ul");
			ul.setAttribute("role", "tablist");
			tabGroup.insertBefore(ul, tabGroup.firstChild);


			const wrapper = document.createElement("div");
			wrapper.className = "tab-scroll-wrapper";

			const scroller = document.createElement("div");
			scroller.className = "tab-scroll";

			const leftArrow = document.createElement("div");
			leftArrow.className = "tab-scroll-arrow left";

			leftArrow.setAttribute("aria-hidden", "true");

			leftArrow.innerHTML = `
				
					
				
			`;

			const rightArrow = document.createElement("div");
			rightArrow.className = "tab-scroll-arrow right";

			rightArrow.setAttribute("aria-hidden", "true");

			rightArrow.innerHTML = `
				
					
				
			`;

			ul.parentNode.insertBefore(wrapper, ul);
			wrapper.appendChild(scroller);
			wrapper.appendChild(leftArrow);
			wrapper.appendChild(rightArrow);
			scroller.appendChild(ul);

			const updateScrollState = () => {
				const isOverflowing = scroller.scrollWidth > scroller.clientWidth;

				wrapper.classList.toggle("is-overflowing", isOverflowing);

				const atStart = scroller.scrollLeft <= 1;
				const atEnd = scroller.scrollLeft + scroller.clientWidth >= scroller.scrollWidth - 2;

				leftArrow.classList.toggle("hidden", !isOverflowing || atStart);
				rightArrow.classList.toggle("hidden", !isOverflowing || atEnd);
				
				
    wrapper.classList.toggle("show-left", isOverflowing && !atStart);
    wrapper.classList.toggle("show-right", isOverflowing && !atEnd);

			};

			const scrollAmount = 15;   // smaller = smoother per frame
			const scrollInterval = 16; // ~60fps

			let scrollTimer = null;

			const startScrolling = (direction) => {
				if (scrollTimer) return;

				let speed = 0.1;

				scrollTimer = setInterval(() => {
					speed = Math.min(speed + 0.01, 5); // ramp up
					scroller.scrollBy({
						left: direction * scrollAmount * speed,
						behavior: "auto"
					});
				}, scrollInterval);
			};


			const stopScrolling = () => {
				clearInterval(scrollTimer);
				scrollTimer = null;
			};
			leftArrow.addEventListener("mousedown", () => startScrolling(-1));
			leftArrow.addEventListener("mouseup", stopScrolling);
			leftArrow.addEventListener("mouseleave", stopScrolling);

			leftArrow.addEventListener("touchstart", () => startScrolling(-1));
			leftArrow.addEventListener("touchend", stopScrolling);

			rightArrow.addEventListener("mousedown", () => startScrolling(1));
			rightArrow.addEventListener("mouseup", stopScrolling);
			rightArrow.addEventListener("mouseleave", stopScrolling);

			rightArrow.addEventListener("touchstart", () => startScrolling(1));
			rightArrow.addEventListener("touchend", stopScrolling);

			const ARROW_ZONE = 40;

			wrapper.addEventListener("contextmenu", (e) => {
				const isOverflowing = wrapper.classList.contains("is-overflowing");
				if (!isOverflowing) return;

				const rect = wrapper.getBoundingClientRect();

				const isLeftZone = e.clientX < rect.left + ARROW_ZONE;
				const isRightZone = e.clientX > rect.right - ARROW_ZONE;

				if (isLeftZone || isRightZone) {
					e.preventDefault();
				}
			});


			const scrollClickAmount = 150;

			leftArrow.addEventListener("click", () => {
					scroller.scrollBy({ left: -scrollClickAmount, behavior: "smooth" });
			});

			rightArrow.addEventListener("click", () => {
					scroller.scrollBy({ left: scrollClickAmount, behavior: "smooth" });
			});

			scroller.addEventListener("scroll", updateScrollState);
			window.addEventListener("resize", updateScrollState);

			updateScrollState();
			ul.innerHTML = spacer !== true ? tablist : `${tablist}`;

			requestAnimationFrame(updateScrollState);
			setTimeout(updateScrollState, 50);

			ul.innerHTML = spacer !== true ? tablist : `${tablist}`;

			// Tab ordering
			if (tabGroup.hasAttribute("order") || tabGroup.hasAttribute("data-order")) {

				const order = (
					tabGroup.getAttribute("order") ||
					tabGroup.getAttribute("data-order")
				).split(",");

				const items = Array.from(ul.getElementsByTagName("li"));

				items.sort((a, b) => {
					const aa = order.indexOf(a.textContent.trim());
					const bb = order.indexOf(b.textContent.trim());
					if (aa === -1) return 1;
					if (bb === -1) return -1;
					return aa - bb;
				});

				ul.innerHTML = "";
				items.forEach(item => ul.appendChild(item));
				chooseTab(items[0]);
			}

			// Tab event handlers
			tabGroup.querySelectorAll('[role="tab"]').forEach(tab => {

				tab.addEventListener("click", (evt) => {

					const tabId = tab.getAttribute("id");
					const hash = `${tabNamespace}${tabId}`;

					chooseTab(evt.currentTarget);

					window.history.pushState(
						{ tab: tabId },
						"",
						`${location.pathname}${location.search}${hash}`
					);

					pushStateCount++;
				});

				tab.addEventListener("keypress", (e) => {
					if (e.which === 32 || e.which === 13) {
						e.preventDefault();
						e.currentTarget.dispatchEvent(click);
					}
				});
			});

			ul.querySelector("li")?.classList.add("selected");
		}
	});

	// Initial hash handling (tabs only)
	if (location.hash.startsWith(tabNamespace)) {
		const tabId = location.hash.replace(tabNamespace, "");
		waitForElement(`#${tabId}`).then(el => {
			el.focus();
			el.dispatchEvent(click);
		});
	}

	// History navigation (tabs only)
	window.addEventListener("popstate", (e) => {
		if (!location.hash.startsWith(tabNamespace)) return;
		if (e.state?.tab) {
			const tab = document.querySelector(`#${e.state.tab}`);
			if (tab) chooseTab(tab);
		}
	});
}