---
/**
* Tabs — a sliding indicator between tabs, zero JS.
*
* Tabs are radio inputs (arrow keys, focus, one selected: native). :has()
* turns the checked index into a custom property; the indicator translates
* by it on the active preset's curve and the matching panel is shown with a
* @starting-style entrance. Up to eight tabs. `direction` lays the panels
* out as one horizontal track that translates by the same index, so the
* panel slides the way you moved — 3 → 1 comes in from the left — with no
* knowledge of the previous tab; the track is as tall as its tallest panel
* and the panels off-screen are visibility: hidden once the slide ends.
* Right to left, tab 1 sits on the right and both movements run the other
* way.
*/
import type { HTMLAttributes } from 'astro/types';
interface Tab {
label: string;
/** HTML string. */
body: string;
}
interface Props extends HTMLAttributes<'div'> {
tabs: Tab[];
/** Radio group name and id prefix; unique per page. */
id: string;
/** Accessible name of the group, e.g. "Package manager". */
label?: string;
/** Index selected on load. */
active?: number;
/** Panels slide sideways in the direction of the change instead of fading in place. */
direction?: boolean;
}
const { tabs, id, label, active = 0, direction = false, class: className, style, ...rest } = Astro.props;
const list = tabs.slice(0, 8);
// the caller's style joins the tab count instead of replacing it (the columns and the indicator's width read it)
const vars = [`--ma-tabs-n:${list.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-tabs', className]} data-direction={direction ? '' : undefined} style={vars} {...rest}>
<div class="ma-tabs__list" role="radiogroup" aria-label={label}>
{
list.map((t, i) => (
<>
<input type="radio" name={id} id={`${id}-${i}`} class="ma-tabs__input" checked={i === active} />
<label for={`${id}-${i}`} class="ma-tabs__tab">{t.label}</label>
</>
))
}
<span class="ma-tabs__indicator" aria-hidden="true"></span>
</div>
<div class="ma-tabs__panels">
{list.map((t) => <div class="ma-tabs__panel" set:html={t.body} />)}
</div>
</div>
<style is:global>
@layer components {
:where(.ma-tabs__list) {
position: relative;
display: grid;
grid-template-columns: repeat(var(--ma-tabs-n, 2), 1fr);
border-bottom: 1px solid var(--ma-edge);
}
:where(.ma-tabs__input) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
:where(.ma-tabs__tab) {
padding: 0.625rem 0.75rem;
text-align: center;
cursor: pointer;
opacity: var(--ma-dim, 0.7);
font-weight: 500;
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-tabs__tab:hover),
:where(.ma-tabs__input:checked + .ma-tabs__tab) {
opacity: 1;
}
:where(.ma-tabs__input:focus-visible + .ma-tabs__tab) {
outline: 2px solid currentColor;
outline-offset: -2px;
border-radius: 0.375rem;
}
:where(.ma-tabs__indicator) {
position: absolute;
inset-inline-start: 0;
bottom: -1px;
width: calc(100% / var(--ma-tabs-n, 2));
height: 2px;
background: currentColor;
translate: calc(var(--ma-tab, 0) * 100% * var(--ma-tabs-dir, 1)) 0;
transition: translate var(--ma-duration) var(--ma-ease);
}
/* Right to left the first tab is on the right, so the indicator (and the direction track) translate the other
way. [dir] and not :dir(): Chrome has :dir() only from 120, and a build that targets older engines rewrites it
into a guess from lang, which a page that sets only dir never matches. An ltr island one level inside an rtl
page is set back. */
:where([dir='rtl'] .ma-tabs, .ma-tabs[dir='rtl']) { --ma-tabs-dir: -1; }
:where([dir='rtl'] [dir='ltr'] .ma-tabs, [dir='rtl'] .ma-tabs[dir='ltr']) { --ma-tabs-dir: 1; }
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(2):checked)) { --ma-tab: 1; }
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(3):checked)) { --ma-tab: 2; }
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(4):checked)) { --ma-tab: 3; }
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(5):checked)) { --ma-tab: 4; }
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(6):checked)) { --ma-tab: 5; }
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(7):checked)) { --ma-tab: 6; }
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(8):checked)) { --ma-tab: 7; }
:where(.ma-tabs__panel) {
display: none;
padding-top: 1rem;
opacity: 1;
translate: 0 0;
transition: opacity var(--ma-duration) var(--ma-ease), translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(1):checked) .ma-tabs__panel:nth-child(1)),
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(2):checked) .ma-tabs__panel:nth-child(2)),
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(3):checked) .ma-tabs__panel:nth-child(3)),
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(4):checked) .ma-tabs__panel:nth-child(4)),
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(5):checked) .ma-tabs__panel:nth-child(5)),
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(6):checked) .ma-tabs__panel:nth-child(6)),
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(7):checked) .ma-tabs__panel:nth-child(7)),
:where(.ma-tabs:has(.ma-tabs__input:nth-of-type(8):checked) .ma-tabs__panel:nth-child(8)) {
display: block;
}
@starting-style {
:where(.ma-tabs__panel) {
opacity: 0;
translate: 0 var(--ma-travel-hover, 4px);
}
}
/* direction: every panel is laid out in one row of equal columns; the row translates by the checked index,
so the slide goes the way the selection moved. Off-screen panels hide once the slide has ended (visibility
interpolates as visible for the whole transition) so focus never lands in them. The clip sits on the root:
a row that clips itself and then translates carries its clip along, and every panel after the first was cut
away. */
:where(.ma-tabs[data-direction]) {
overflow-x: clip;
}
:where(.ma-tabs[data-direction] .ma-tabs__panels) {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
translate: calc(var(--ma-tab, 0) * -100% * var(--ma-tabs-dir, 1)) 0;
transition: translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-tabs[data-direction] .ma-tabs__panel) {
display: block;
visibility: hidden;
opacity: 0.4;
translate: 0 0;
transition:
visibility var(--ma-duration),
opacity var(--ma-duration) var(--ma-ease);
}
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(1):checked) .ma-tabs__panel:nth-child(1)),
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(2):checked) .ma-tabs__panel:nth-child(2)),
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(3):checked) .ma-tabs__panel:nth-child(3)),
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(4):checked) .ma-tabs__panel:nth-child(4)),
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(5):checked) .ma-tabs__panel:nth-child(5)),
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(6):checked) .ma-tabs__panel:nth-child(6)),
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(7):checked) .ma-tabs__panel:nth-child(7)),
:where(.ma-tabs[data-direction]:has(.ma-tabs__input:nth-of-type(8):checked) .ma-tabs__panel:nth-child(8)) {
visibility: visible;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-tabs__indicator),
:where(.ma-tabs__panel),
:where(.ma-tabs[data-direction] .ma-tabs__panels) {
transition: none;
}
}
}
</style>