78 UI Kit
Components

Tabs

Point a tab at a panel with data-tab and you are done. Roles, aria-selected, a roving tabindex and the arrow keys are all wired on load. The segmented control at the bottom of this page looks similar and is a different component — it owns a value, not a panel.

The markup contract

A _78-tabs list of _78-tab buttons, each with data-tab="<panel id>", and the panels anywhere in the document. That is the whole contract — _78.tabs mounts on load and adds the rest, including _78-tabpanel on each panel.

Try it: click a tab, then move with ← / →, Home and End. Every change fires _78:tabchange, which bubbles.

Four workspaces are above 80% of their seat allowance.

Next invoice is issued on the first of the month.

18 configuration changes in the last 30 days.

Nothing archived.

tabs
<div class="site-demo-block">
  <div class="_78-tabs">
    <button class="_78-tab" data-tab="t-usage">Usage <span class="_78-badge _78-badge-accent">4</span></button>
    <button class="_78-tab" data-tab="t-billing">Billing</button>
    <button class="_78-tab" data-tab="t-audit">Audit log</button>
    <button class="_78-tab" data-tab="t-archived" disabled>Archived</button>
  </div>
  <div id="t-usage"><p>Four workspaces are above 80% of their seat allowance.</p></div>
  <div id="t-billing"><p>Next invoice is issued on the first of the month.</p></div>
  <div id="t-audit"><p>18 configuration changes in the last 30 days.</p></div>
  <div id="t-archived"><p>Nothing archived.</p></div>
</div>

A tab with disabled (or aria-disabled="true") is dimmed and skipped. The panels themselves need no classes — _78-tabpanel and hidden are applied for you.

Choosing the initial tab

By default the first tab wins. Mark a different one with _78-active — or with aria-selected="true", which is equivalent — and it is selected on mount.

Not this one.

This panel opens first, because its tab has _78-active.

Nor this one.

initial tab
<div class="site-demo-block">
  <div class="_78-tabs">
    <button class="_78-tab" data-tab="t2-overview">Overview</button>
    <button class="_78-tab _78-active" data-tab="t2-members">Members</button>
    <button class="_78-tab" data-tab="t2-settings">Settings</button>
  </div>
  <div id="t2-overview"><p>Not this one.</p></div>
  <div id="t2-members"><p>This panel opens first, because its tab has <code>_78-active</code>.</p></div>
  <div id="t2-settings"><p>Nor this one.</p></div>
</div>

Segmented variant

Add _78-tabs-segmented for the compact segmented-control look — a filled track with the selected tab raised out of it. It suits short, mutually exclusive choices such as a time range. Give it display:inline-flex if you do not want it to span the container.

Today's activity.

This week's activity.

This month's activity.

segmented tabs
<div class="site-demo-block">
  <div class="_78-tabs _78-tabs-segmented" style="display:inline-flex">
    <button class="_78-tab" data-tab="t3-day">Day</button>
    <button class="_78-tab" data-tab="t3-week">Week</button>
    <button class="_78-tab" data-tab="t3-month">Month</button>
  </div>
  <div id="t3-day"><p class="_78-text-sm">Today's activity.</p></div>
  <div id="t3-week"><p class="_78-text-sm">This week's activity.</p></div>
  <div id="t3-month"><p class="_78-text-sm">This month's activity.</p></div>
</div>
Still tabs, only smaller
Every button here shows a panel. If what you want is a control that holds a value — a range, a chart type, a unit — reach for _78-seg instead. It looks almost identical and is a different component with different ARIA.

JavaScript API — _78.tabs

Tabs that exist at load need no JavaScript. For a tab list you build later, mount it yourself — mounting is idempotent, so calling it twice on the same list is safe and returns the same controller.

_78.tabs
// Mount one list, or everything inside a container you just inserted.
var tabs = _78.tabs.mount(document.querySelector('._78-tabs'));
_78.tabs.mountAll(myNewlyAddedPanel);   // scope is optional; defaults to document

// mount() returns the controller, which is also parked on the element
// as list._78tabs — handy when you did not keep the return value.
tabs.el;         // the ._78-tabs element
tabs.tabs;       // array of the ._78-tab buttons
tabs.select(1);              // select by index
tabs.select(1, true);        // …and move focus to it

Listen for changes on the list, or anywhere above it — the event bubbles:

_78:tabchange
document.addEventListener('_78:tabchange', function (e) {
  // e.detail = { index, tab, panel, id }
  if (e.detail.id === 'tab-reports') refreshReports();
});
MemberWhat it is
_78.tabs.mount(list)Mount one _78-tabs element. Returns the controller, or null if it holds no tabs.
_78.tabs.mountAll(scope)Mount every unmounted tab list inside scope (default document).
list._78tabsThe controller for an already-mounted list.
controller.select(i, moveFocus)Select by index. Pass true to also focus the tab.
controller.tabsThe tab buttons, in order.
_78:tabchangeFired on the list and bubbles. detail = { index, tab, panel, id }.

Segmented control — _78-seg

A joined run of buttons where exactly one is active. It looks like the segmented tabs above and does a different job: a segmented control owns a value, not a panel. Nothing is shown or hidden when you click one — it changes what a value is, and tells you so with an event. A range switch, a chart type, kilometers or miles.

Which of the three

ComponentUse it forARIAEvent
_78-tabs _78-tabs-segmented Showing one panel of several, in a compact style. tablist / tab _78:tabchange
_78-seg Choosing one value of several. Nothing is revealed. radiogroup / radio _78:segchange
_78-btn-group Several independent actions, spaced rather than joined — every button its own. See Buttons. — plain buttons — your own click

Markup and behavior

A _78-seg element around _78-seg-btn buttons. Each option's value is its data-value, falling back to the button's own text. Mark the starting option with aria-checked="true" (aria-pressed="true" or _78-active work too); with none of them, the first option wins — and that initial state does not fire an event.

Try it: click, then move with ← / →. The group is a single tab stop, the arrow keys step over the disabled option, and Home / End jump to the first and last enabled ones.

Change it — the event lands here.

_78-seg
<div class="_78-seg" aria-label="Range">
  <button class="_78-seg-btn" data-value="1d">1D</button>
  <button class="_78-seg-btn" data-value="1w" aria-checked="true">1W</button>
  <button class="_78-seg-btn" data-value="1m">1M</button>
  <button class="_78-seg-btn" data-value="1y">1Y</button>
  <button class="_78-seg-btn" data-value="all" disabled>All</button>
</div>

A _78-seg-btn with no type is given type="button" on mount, so dropping one inside a <form> cannot submit it by accident.

Variants

Default
_78-seg-sm _78-seg-accent
_78-seg-full
seg variants
<div class="site-demo-block">
  <div>
    <div class="_78-eyebrow _78-mb-8">Default</div>
    <div class="_78-seg" aria-label="Chart type">
      <button class="_78-seg-btn">Line</button>
      <button class="_78-seg-btn">Bars</button>
      <button class="_78-seg-btn">Candles</button>
    </div>
  </div>
  <div>
    <div class="_78-eyebrow _78-mb-8">_78-seg-sm _78-seg-accent</div>
    <div class="_78-seg _78-seg-sm _78-seg-accent" aria-label="Units">
      <button class="_78-seg-btn" aria-checked="true">Miles</button>
      <button class="_78-seg-btn">Kilometers</button>
    </div>
  </div>
  <div>
    <div class="_78-eyebrow _78-mb-8">_78-seg-full</div>
    <div class="_78-seg _78-seg-full" aria-label="Billing period">
      <button class="_78-seg-btn">Monthly</button>
      <button class="_78-seg-btn" aria-checked="true">Yearly</button>
      <button class="_78-seg-btn">Two years</button>
    </div>
  </div>
</div>
Class / attributeWhat it does
_78-segThe group: a --bg3 inset track with a --border edge and 3px of padding. Scrolls sideways rather than wrapping if the options overflow.
_78-seg-btnOne option. The active one is raised onto a --bg2 surface with an accent label.
_78-seg-smSmaller: --control-h-sm tall, --fs-sm type.
_78-seg-fullFills its container, every option an equal share.
_78-seg-accentThe active option becomes an accent fill instead of a raised surface.
data-valueThe option's value. Falls back to the button's text.
aria-checked="true"Which option starts selected. aria-pressed="true" and _78-active are equivalent.
disabledDims the option and takes it out of the arrow-key run.

JavaScript API — _78.seg

_78.seg.mountAll() runs on load over every _78-seg in the page, so a group written in your HTML needs no JavaScript at all. Mount the ones you inject later.

_78.seg
var seg = _78.seg.mount(document.querySelector('._78-seg'));

seg.value;        // '1w' — data-value, falling back to the button's text
seg.index;        // 1
seg.buttons;      // the option elements, in order
seg.select(2);            // select by index
seg.select(2, true);      // …and move focus to it

_78.seg.mountAll(myNewPanel);   // scope optional; defaults to document
_78:segchange
document.addEventListener('_78:segchange', function (e) {
  // e.detail = { index, button, value } — the event bubbles from the group
  if (e.target.id === 'range') loadRange(e.detail.value);
});
MemberWhat it is
_78.seg.mount(el)Mount one group. Returns the controller, or null if it holds no options. Idempotent.
_78.seg.mountAll(scope)Mount every unmounted group inside scope (default document).
el._78segThe controller for an already-mounted group.
seg.value · seg.indexThe current value and its position.
seg.buttonsThe option buttons, in order.
seg.select(i, moveFocus)Select by index. Pass true to also focus it.
_78:segchangeFired on the group and bubbles. detail = { index, button, value }.
Label the group with what the value is
Give it an aria-label (or aria-labelledby) naming the setting — “Range”, not “1D 1W 1M”. The options already announce themselves; what a screen reader cannot infer is what they are options for.