Tabs

Organize content into separate views where only one view is visible at a time.

The tabs component horizontally layers content sections in an accessible, conformant way. Keystone provide this component as partials (for template use) and as shortcodes (for content use).

The component leverages the partial decorators(Opens in a new tab) construct introduced in Hugo v0.154.0.

Tabs Pattern

A set of tab elements and their associated tab panels.

A set of tab elements contained in a tablist element.

An element in the tab list that serves as a label for one of the tab panels and can be activated to display that panel.

The element that contains the content associated with a tab.

Go/HTML template
 1{{- with partial "ui/tabs" (dict 
 2  "items" (slice "Tabs" "Tab List" "tab" "tabpanel")
 3  "label" "Tabs pattern definitions"
 4  ) 
 5-}}
 6
 7  <!-- Tab tab -->
 8  {{-  with partial "ui/tab-panel" (dict "item" "Tabs") }}
 9    <div class="border px-4 py-3 rounded-md">
10      <p>A set of tab elements and their associated tab panels.</p>
11    </div>
12  {{- end }}
13
14  <!-- Tab List tab  -->
15  {{-  with partial "ui/tab-panel" (dict "item" "Tab List") }}
16    <div class="border px-4 py-3 rounded-md">
17      <p>A set of tab elements contained in a <code>tablist</code> element.</p>
18    </div>
19  {{- end }}
20
21  <!-- tab tab -->
22  {{-  with partial "ui/tab-panel" (dict "item" "tab") }}
23    <div class="border px-4 py-3 rounded-md">
24      <p>An element in the tab list that serves as a <strong>label</strong> for one of the tab panels and can be activated to display that panel.</p>
25    </div>
26  {{- end }}
27
28  <!-- tabpanel tab -->
29  {{-  with partial "ui/tab-panel" (dict "item" "tabpanel") }}
30    <div class="border px-4 py-3 rounded-md">
31      <p>The element that contains the content associated with a tab.</p>
32    </div>
33  {{- end }}
34
35{{- end }}

Reference

The component is composed of a parent ui/tabs and child ui/tab-panel partials.

Parent: ui/tabs

items
"items" (slice "Tab 1" "Tab 2") — (slice of strings, required)
A list of labels for the tabs.
These must match the item argument in the child partials.
variant
"variant" "pill" — (string, optional)
The visual style of the component.
Options: pill, line (default)
label
"label" "Tabs ARIA descriptor" — (string, optional)
Defines the aria-label for the tab list

Child: ui/tab-panel

item
"item" "Tab 1" — (string, required)
The identifier for the panel.
Must match one of the strings in the parent items slice.

Installation

The Tabs component is disabled by default to save resources.
Uncomment the imports in the following files:

/assets/js/main.js
1- //import tabsModule from './modules/tabs.js';
2+ import tabsModule from './modules/tabs.js';
3
4- //tabsModule(Alpine);
5+ //tabsModule(Alpine);
/assets/css/main.css
1- /* @import './components/_tabs.css'; */
2+ @import './components/_tabs.css';
Prerequisites
The Get Started: Manual Installation steps must be completed!

Javascript

Create the Alpine module:

Alpine Js /assets/js/modules/tabs.js
  1export default function tabsModule(Alpine) {
  2  Alpine.data('ksTabs', (config = {}) => ({
  3    current: config.default || '',
  4    items: [],
  5
  6    init() {
  7      // Scan and build the index
  8      this.items = Array.from(this.$root.querySelectorAll('[role="tab"]')).map(
  9        (el) => el.dataset.tab,
 10      );
 11
 12      // Priority: URL Hash > Config Default > First Item
 13      const hash = window.location.hash.replace('#', '');
 14      const matchingItem = this.items.find((item) => this.idName(item) === hash);
 15
 16      if (matchingItem) {
 17        this.current = matchingItem;
 18      } else if (!this.current && this.items.length > 0) {
 19        this.current = this.items[0];
 20      }
 21    },
 22
 23    set(name) {
 24      if (this.current === name) return;
 25      this.current = name;
 26    },
 27
 28    // Protocol: Strict Kebab Case
 29    // Matches Hugo's logic: lower | replaceRE "[^a-z0-9]+" "-" | trim "-"
 30    idName(name) {
 31      return name
 32        .toString() // Safety
 33        .toLowerCase()
 34        .trim()
 35        .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with dash
 36        .replace(/^-+|-+$/g, ''); // Remove leading/trailing dashes
 37    },
 38
 39    isSelected(name) {
 40      return this.current === name;
 41    },
 42
 43    next() {
 44      let index = this.items.indexOf(this.current);
 45      let nextIndex = (index + 1) % this.items.length;
 46      this.activate(this.items[nextIndex]);
 47    },
 48
 49    prev() {
 50      let index = this.items.indexOf(this.current);
 51      let prevIndex = (index - 1 + this.items.length) % this.items.length;
 52      this.activate(this.items[prevIndex]);
 53    },
 54
 55    activate(item) {
 56      this.set(item);
 57
 58      // Wait for Alpine to update DOM classes, then scroll/focus
 59      this.$nextTick(() => {
 60        const el = this.$root.querySelector(`[data-tab="${item}"]`);
 61        if (el) {
 62          el.focus();
 63          // Smooth scroll the tab into view on mobile
 64          el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
 65        }
 66      });
 67    },
 68
 69    trigger(name, variant = 'line') {
 70      const safeName = this.idName(name);
 71      return {
 72        [':id']() {
 73          return this.$id('ks-tab', safeName);
 74        },
 75        [':aria-controls']() {
 76          return this.$id('ks-panel', safeName);
 77        },
 78
 79        ['@click']() {
 80          this.activate(name);
 81        },
 82
 83        ['@keydown.right.prevent']() {
 84          this.next();
 85        },
 86        ['@keydown.left.prevent']() {
 87          this.prev();
 88        },
 89        ['@keydown.home.prevent']() {
 90          this.activate(this.items[0]);
 91        },
 92        ['@keydown.end.prevent']() {
 93          this.activate(this.items[this.items.length - 1]);
 94        },
 95
 96        [':aria-selected']() {
 97          return this.isSelected(name);
 98        },
 99        [':tabindex']() {
100          return this.isSelected(name) ? '0' : '-1';
101        },
102
103        [':class']() {
104          const isSelected = this.isSelected(name);
105
106          if (variant === 'pill') {
107            return isSelected ? 'ks-tabs-pill-active' : 'ks-tabs-pill-inactive';
108          } else {
109            return isSelected ? 'ks-tabs-line-active' : 'ks-tabs-line-inactive';
110          }
111        },
112      };
113    },
114  }));
115}

Import and register it in main.js:

Alpine Js /assets/js/main.js
1import tabsModule from './modules/tabs.js';
2tabsModule(Alpine);

Hugo Partials

Create the parent ui/tabs and child ui/tab-panel partials.

Parent Partial

Go/HTML template layouts/_partials/ui/tabs.html
 1{{- $items := .items -}}
 2{{- if not $items }}
 3  {{- errorf "ui/tabs requires 'items'." }}
 4{{- end }}
 5
 6{{- $variant := .variant | default "line" -}}
 7
 8{{- $trackStyle := "" }}
 9{{- if eq $variant "line" }}
10  {{- $trackStyle = "ks-tabs-track" }}
11{{- else }}
12  {{- $trackStyle = "ks-tabs-track-pill" }}
13{{- end }}
14
15{{- $default := index $items 0 -}}
16{{- $safeDefault := $default | lower | replaceRE "[^a-z0-9]+" "-" | replaceRE "^-+|-$" "" -}}
17
18{{- $label:= .label -}}
19
20<div x-data="ksTabs({ default: '{{ $safeDefault }}' })" x-id="['ks-tab', 'ks-panel']" class="ks-tabs-wrapper">
21  <div class="{{ $trackStyle }}">
22    <div
23      class="ks-tabs-tablist"
24      role="tablist"
25      {{- with $label }}
26        aria-label="{{ . }}"
27      {{- end }}
28    >
29      {{- range $items }}
30        {{- /* Protocol: Strict Kebab Case */}}
31        {{- $safeID := . | lower | replaceRE "[^a-z0-9]+" "-" | replaceRE "^-+|-$" "" -}}
32        {{- $variantStyle := "" }}
33        {{- if eq $variant "line" }}
34          {{- $variantStyle = "ks-tabs-line" -}}
35        {{- else }}
36          {{- $variantStyle = "ks-tabs-pill" -}}
37        {{- end }}
38        <button
39          type="button"
40          role="tab"
41          data-tab="{{ $safeID }}"
42          x-bind="trigger('{{ $safeID }}', '{{ $variant }}')"
43          class="ks-tabs-trigger {{ $variantStyle }}"
44        >
45          {{- . -}}
46        </button>
47      {{- end }}
48    </div>
49  </div>
50
51  <div class="ks-tabs-content-wrapper">
52    {{- if .content }}
53      {{- .content | safeHTML }}
54    {{- else }}
55      {{- inner . }}
56    {{ end }}
57  </div>
58</div>

Child Partial

Go/HTML template layouts/_partials/ui/tab-panel.html
 1{{- $item := .item -}}
 2
 3{{- if not $item }}
 4  {{- $item = .label }}
 5{{- end }}
 6
 7{{- $safeID := $item | lower | replaceRE "[^a-z0-9]+" "-" | replaceRE "^-+|-$" "" -}}
 8
 9<div
10  x-show="current === '{{ $safeID }}'"
11  x-cloak
12  role="tabpanel"
13  :id="$id('ks-panel', '{{ $safeID }}')"
14  :aria-labelledby="$id('ks-tab', '{{ $safeID }}')"
15  tabindex="0"
16  class="ks-tabs-content"
17>
18  {{- if .content }}
19    {{- .content }}
20  {{- else }}
21    {{- inner . }}
22  {{- end }}
23</div>

Hugo Shortcodes

Warning
The shortcodes are dependent on the partials above. Ensure the partials are created first.

Create the parent ui/tabs and child ui/tab-panel shortcodes.

Parent Shortcode

Go/HTML template layouts/_shortcodes/ui/tabs.html
 1{{- $items := split (.Get "items") "," -}}
 2{{- $variant := .Get "variant" | default "line" -}}
 3{{- $label := .Get "label" -}}
 4
 5{{- partial "ui/tabs.html" (dict
 6  "items"   $items
 7  "content" .Inner
 8  "variant" $variant
 9  "label"   $label
10  )
11-}}

Child Shortcode

Go/HTML template layouts/_shortcodes/ui/tab-panel.html
 1{{- $item:= .Get 0 -}}
 2
 3{{- $safeName := $item | lower | replaceRE "[^a-z0-9]+" "-" | replaceRE "^-+|-$" "" -}}
 4
 5<div
 6  x-show="current === '{{ $safeName }}'"
 7  x-cloak
 8  role="tabpanel"
 9  :id="$id('ks-panel', '{{ $safeName }}')"
10  :aria-labelledby="$id('ks-tab', '{{ $safeName }}')"
11  tabindex="0"
12  class="ks-tabs-content-shortcode"
13>
14  {{- .Inner | markdownify -}}
15</div>

CSS Styling

Create the CSS component:

Tailwind CSS assets/css/components/_tabs.css
 1@layer components {
 2  /* Tabs */
 3  .ks-tabs-wrapper {
 4    @apply flex w-full flex-col gap-4;
 5  }
 6  .ks-tabs-track {
 7    @apply w-full;
 8  }
 9  .ks-tabs-tablist {
10    @apply flex gap-2;
11    @apply snap-x snap-mandatory overflow-x-auto;
12    @apply p-1;
13    @apply no-scrollbar;
14  }
15  .ks-tabs-trigger {
16    @apply snap-start;
17    @apply shrink-0;
18    @apply text-sm font-medium whitespace-nowrap;
19    @apply transition-all duration-300;
20    @apply focus-visible:border-ring focus-visible:ring-ring focus-visible:outline-ring focus:outline-none focus-visible:ring-2;
21  }
22
23  /** Variant: Line **/
24  .ks-tabs-line {
25    @apply inline-flex items-center justify-center;
26    @apply min-h-8 w-fit px-1 pt-0.75;
27    @apply text-muted-foreground bg-transparent;
28    @apply rounded-none border-b-2;
29  }
30  .ks-tabs-line-active {
31    @apply text-foreground;
32    @apply border-ring;
33  }
34  .ks-tabs-line-inactive {
35    @apply border-transparent;
36    @apply hover:border-ring/70 hover:text-foreground;
37  }
38
39  /** Variant: Pill **/
40  .ks-tabs-track-pill {
41    @apply w-fit;
42    @apply bg-muted/50;
43    @apply rounded-md border;
44  }
45  .ks-tabs-pill {
46    @apply px-2 py-1;
47    @apply border-border/70 rounded-md border;
48  }
49  .ks-tabs-pill-active {
50    @apply bg-background;
51    @apply border-border;
52    @apply shadow-sm;
53  }
54  .ks-tabs-pill-inactive {
55    @apply text-muted-foreground bg-muted;
56    @apply hover:bg-background/70 hover:border-border hover:text-foreground/70 hover:shadow-sm;
57  }
58
59  /** Tabpanel Styles **/
60  .ks-tabs-content-wrapper {
61    @apply w-full;
62    @apply text-sm;
63  }
64  .ks-tabs-content {
65    @apply flex-1;
66  }
67  /* .ks-tabs-content-shortcode {} */
68}

Import it in your main Tailwind file:

Tailwind CSS assets/main.css
1/* Components */
2/** (Import Keystone components CSS here) */
3@import './components/_tabs.css';

Usage

Use tabs to toggle between mutually exclusive options or content in your templates.

Using Partials (Templates)

When working inside layout files (e.g. layouts/home.html), use the with context pattern:

HTML layouts/home.html
 1<div class="mx-auto my-24 w-full max-w-2xl px-4">
 2  {{- with partial "ui/tabs" (dict
 3    "items" (slice "Git Submodule" "Hugo Module" "Git Clone")
 4    "label" "Theme Installation Method"
 5    "variant" "pill"
 6  )}}
 7
 8  <!-- Git Submodule -->
 9  {{- with partial "ui/tab-panel" (dict "item" "Git Submodule") }}
10    <div class="w-fit space-y-4">
11      <p>Best for maintaining a link to the original repo while keeping versions locked.</p>
12      <div class="bg-muted text-foreground rounded-md border p-4 font-mono text-sm">
13        <span class="text-muted-foreground/80">$</span> git submodule add https://github.com/user/my-theme.git themes/my-theme
14      </div>
15    </div>
16  {{- end }}
17
18  <!-- Hugo Module -->
19  {{- with partial "ui/tab-panel" (dict "item" "Hugo Module") }}
20    <div class="w-fit space-y-4">
21      <p>Best for modern Go-based setups. Requires <code>go</code> to be installed.</p>
22      <div class="bg-muted text-foreground rounded-md border p-4 font-mono text-sm">
23        <span class="text-muted-foreground/80">$</span> hugo mod init my-project<br />
24        <span class="text-muted-foreground/80">$</span> hugo mod get github.com/user/my-theme
25      </div>
26    </div>
27  {{- end }}
28
29  <!-- Manual Clone (Quick) -->
30  {{- with partial "ui/tab-panel" (dict "item" "Git Clone") }}
31    <div class="w-fit space-y-4">
32      <p>Best for testing.</p>
33      <div class="bg-muted text-foreground rounded-md border p-4 font-mono text-sm">
34        <span class="text-muted-foreground/80">$</span> git clone https://github.com/user/my-theme.git themes/my-theme
35      </div>
36    </div>
37  {{- end }}
38
39{{- end }}
40</div>

Using Shortcodes (Markdown)

When working inside content files (e.g. content/install.md), use the shortcode syntax:

Markdown content/install.md
 1{{< ui/tabs
 2  items="Git Submodule,Hugo Module,Git Clone"
 3  label="Theme Installation Method"
 4  variant="pill"
 5>}}
 6
 7{{< ui/tab-panel "Git Submodule" >}}
 8Best for maintaining a link to the original repo while keeping versions locked.
 9```bash
10  $ git submodule add https://github.com/user/my-theme.git themes/my-theme
11```
12{{< /ui/tab-panel >}}
13
14{{< ui/tab-panel  "Hugo Module" >}}
15Best for modern Go-based setups. Requires `go` to be installed.
16```bash
17  $ hugo mod init my-project
18  $ hugo mod get github.com/user/my-theme
19```
20{{< /ui/tab-panel >}}
21
22{{< ui/tab-panel "Git Clone" >}}
23Best for testing.
24```bash
25  $ git clone https://github.com/user/my-theme.git themes/my-theme
26```
27{{< /ui/tab-panel >}}
28
29{{< /ui/tabs >}}

Tips & tricks

Internationalization (i18n)

For multilingual templates add the same i18n string as items/item.

HTML layouts/home.html
 1{{- with partial "ui/tabs" (dict 
 2  "items" (slice 
 3    (i18n "Install" | default "Install") 
 4    (i18n "Update" | default "Update")
 5  )
 6  "label" (i18n "InstallTabPanel" | default "Install and update steps")
 7  ) 
 8-}}
 9
10  <!-- Tab: Install -->
11  {{- with partial "ui/tab-panel" (dict 
12      "item" (i18n "Install" | default "Install")
13    ) 
14  -}}
15    <h2>{{ i18n "InstallStepa" | default "Install Stepa" }}</h2>
16    <!-- Rest of the Content -->
17  {{- end }}
18
19  <!-- Tab: Update -->
20  {{- with partial "ui/tab-panel" (dict 
21      "item" (i18n "Update" | default "Update")
22    ) 
23  -}}
24    <h2>{{ i18n "UpdateSteps" | default "Update Steps" }}</h2>
25    <!-- Rest of the Content -->
26  {{- end }}
27
28{{- end }}

Accessibility

The component follows the WAI-ARIA Tabs Pattern(Opens in a new tab) and the WCAG 2.2 Criteria:

  1. Screen Reader Ready: Automatically generates the required links between buttons and panels.
  2. Smart Focus: Only the active tab is placed in the tab order. Users can TAB past the entire list.
  3. Keyboard Traversal: Users can navigate the list using arrow keys ( ) without needing to press ENTER on every item.
  4. Mobile Friendly: Hides Tabs that overflow horizzontaly and scrolls the active tab into view on small screens to prevent navigation from being hidden.