Components
Tabs
tty.css styles horizontal tabs and panels. Your application supplies the interaction.
Usage
Use .tabs with a direct child role="tablist" and sibling role="tabpanel" elements. Connect unique tab and panel IDs with aria-controls and aria-labelledby; application JavaScript updates aria-selected, tabindex, and native hidden.
Examples
Copy the markup under Code and download its No lib or Alpine.js helper. These scripts and the data-tabs hook are example code, not part of tty.css.
The outer example switcher uses .tabs.flat to reduce nested shadows; the inner demos keep their default frames.
Vector search: median latency 18 ms, recall 98.4%.
Full-text search: median latency 9 ms, throughput 640 queries/s.
Code
<script defer src="../assets/tabs.js"></script>
<div class="tabs" data-tabs>
<div role="tablist" aria-label="Query performance">
<button type="button" role="tab" id="perf-vector-tab" aria-controls="perf-vector-panel" aria-selected="true" tabindex="0">Vector Perf</button>
<button type="button" role="tab" id="perf-text-tab" aria-controls="perf-text-panel" aria-selected="false" tabindex="-1">Full-Text Perf</button>
</div>
<section role="tabpanel" id="perf-vector-panel" aria-labelledby="perf-vector-tab" tabindex="0">
<p>Vector search: median latency 18 ms, recall 98.4%.</p>
</section>
<section role="tabpanel" id="perf-text-panel" aria-labelledby="perf-text-tab" tabindex="0" hidden>
<p>Full-text search: median latency 9 ms, throughput 640 queries/s.</p>
</section>
</div>Vector search: median latency 18 ms, recall 98.4%.
Full-text search: median latency 9 ms, throughput 640 queries/s.
Code
<!-- Keep this order: register the component before Alpine starts. -->
<script defer src="../assets/tabs-alpine.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.16.3/dist/cdn.min.js"></script>
<div class="tabs" x-data="exampleTabs">
<div role="tablist" aria-label="Query performance" @keydown="moveTab($event)" @focusout="if (!$el.contains($event.relatedTarget)) tabStop = activeTab">
<button type="button" role="tab" id="alpine-vector-tab" aria-controls="alpine-vector-panel" aria-selected="true" :aria-selected="activeTab === 0" tabindex="0" :tabindex="tabStop === 0 ? 0 : -1" @focus="focusTab(0, $el)" @click="activateTab(0, $el)">Vector Perf</button>
<button type="button" role="tab" id="alpine-text-tab" aria-controls="alpine-text-panel" aria-selected="false" :aria-selected="activeTab === 1" tabindex="-1" :tabindex="tabStop === 1 ? 0 : -1" @focus="focusTab(1, $el)" @click="activateTab(1, $el)">Full-Text Perf</button>
</div>
<section role="tabpanel" id="alpine-vector-panel" aria-labelledby="alpine-vector-tab" tabindex="0" :hidden="activeTab !== 0">
<p>Vector search: median latency 18 ms, recall 98.4%.</p>
</section>
<section role="tabpanel" id="alpine-text-panel" aria-labelledby="alpine-text-tab" tabindex="0" hidden :hidden="activeTab !== 1">
<p>Full-text search: median latency 9 ms, throughput 640 queries/s.</p>
</section>
</div>←/→ and Home/End move focus without selecting; Enter/Space activate. Tab leaves the strip; returning enters at the selected tab. See MDN, the APG tabs pattern, and the dashboard example.
Frame treatments
Put frame classes on .tabs to coordinate the tab strip and panel. Set --tty-frame-radius to round exposed corners; joined edges stay square. These change appearance, not interaction.
Find the right plan for your team.
Get help with your deployment.
<div class="tabs double-border muted strong-shadow" data-tabs>
<!-- Keep the same connected tablist and panel markup shown above. -->
</div>