Accordion

A vertically stacked set of interactive headings that reveal or hide content sections.

The accordion component vertically layers content sections to optimize vertical space. Keystone provides 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.

This is a heated debate! While some believe it’s a sweet masterpiece, others think it’s a direct violation of international pizza law.
The box is square because folding a round cardboard box requires a PhD in Origami and three times the storage space.
“Leftover pizza” is a theoretical concept, much like “infinite clean energy.”
In reality, the half-life of a pizza in a house with at least one human is approximately 14 minutes.
Go/HTML template
 1{{- with partial "ui/accordion" (dict "level" 2) }}
 2
 3  <!-- First Item -->
 4  {{- with partial "ui/accordion-item" (dict 
 5    "title" "Is pineapple on pizza a crime or a delicacy?"
 6    "icon"  "pizza"
 7    ) 
 8  }}
 9    <p>This is a heated debate! While some believe it's a sweet masterpiece, others think it's a <strong>direct violation of international pizza law.</strong></p>
10  {{- end }}
11
12  <!-- Second Item -->
13  {{- with partial "ui/accordion-item" (dict 
14    "title" "Why is a round pizza delivered in a square box?"
15    ) 
16  }}
17    <p>The box is square because folding a round cardboard box requires a <strong>PhD in Origami</strong> and three times the storage space.</p>
18  {{- end }}
19
20  <!-- Third Item -->
21  {{- with partial "ui/accordion-item" (dict 
22    "title" `How long does "Leftover Pizza" actually last?`
23    ) 
24  }}
25    <p><em>"Leftover pizza"</em> is a theoretical concept, much like <em>"infinite clean energy."</em><br>
26    In reality, the half-life of a pizza in a house with at least one human is approximately <strong>14 minutes</strong>.</p>
27  {{- end }}
28
29{{- end }}

Reference

The component is composed of a parent ui/accordion and child ui/accordion-item partials.

Parent: ui/accordion

multiple
"multiple" true — (boolean, optional)
Allows multiple accordion items to remain expanded simultaneously.
Default: false.
level
"level" 2 — (integer, required)
Sets the aria-level for the internal heading roles.
This must be chosen to match the semantic heading hierarchy of your page.
Fallback: 2

Child: ui/accordion-item

title
"title" "First Item" — (string, required)
The text displayed in the accordion trigger button.
icon
"icon" "pizza" — (string, optional)
Name of a icon. Overrides the default chevron-down icon.
The Icon component is required to override icons.

Installation

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

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

Javascript

Create the Alpine module:

Alpine Js /assets/js/modules/accordion.js
 1export default function accordionModule(Alpine) {
 2  Alpine.data('ksAccordion', (allowMultiple = false) => ({
 3    activeValues: [],
 4    multiple: allowMultiple === true || allowMultiple === 'true',
 5
 6    toggle(id) {
 7      const isAlreadyOpen = this.activeValues.includes(id);
 8
 9      if (this.multiple) {
10        // MULTIPLE MODE
11        if (isAlreadyOpen) {
12          this.activeValues = this.activeValues.filter((i) => i !== id);
13        } else {
14          this.activeValues.push(id);
15        }
16      } else {
17        // SINGLE MODE If it's closed, make it the ONLY open one.
18        if (isAlreadyOpen) {
19          this.activeValues = [];
20        } else {
21          this.activeValues = [id];
22        }
23      }
24    },
25
26    isOpen(id) {
27      return this.activeValues.includes(id);
28    },
29
30    handleKeydown(e) {
31      const buttons = Array.from(this.$el.querySelectorAll('[data-accordion-trigger]'));
32      const currentIndex = buttons.indexOf(e.target);
33      if (currentIndex === -1) return;
34
35      switch (e.key) {
36        case 'ArrowDown':
37          e.preventDefault();
38          buttons[(currentIndex + 1) % buttons.length].focus();
39          break;
40        case 'ArrowUp':
41          e.preventDefault();
42          buttons[(currentIndex - 1 + buttons.length) % buttons.length].focus();
43          break;
44        case 'Home':
45          e.preventDefault();
46          buttons[0].focus();
47          break;
48        case 'End':
49          e.preventDefault();
50          buttons[buttons.length - 1].focus();
51          break;
52      }
53    },
54  }));
55}

Import and register it in main.js:

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

Hugo Partials

Create the parent ui/accordion and child ui/accordion-item partials.

Parent Partial

Go/HTML template layouts/_partials/ui/accordion.html
 1{{- $multiple := false -}}
 2{{- $level := .level | default 2 | int -}}
 3
 4{{- if reflect.IsMap . }}
 5  {{- $multiple = .multiple | default false -}}
 6{{- end }}
 7
 8{{- $jsMultiple := "false" -}}
 9{{- if or (eq $multiple true) (eq $multiple "true") }}
10  {{- $jsMultiple = "true" -}}
11{{- end }}
12
13{{- $prevLevel := .Page.Store.Get "ks_accordion_level" -}}
14{{- .Page.Store.Set "ks_accordion_level" $level -}}
15
16{{- $content := "" -}}
17{{- if .content }}
18  {{- $content = .content | strings.TrimSpace -}}
19{{- else }}
20  {{- $content = inner . | strings.TrimSpace -}}
21{{- end }}
22
23{{- $targetStr := "data-ks-level-placeholder" -}}
24{{- $replacementStr := printf "aria-level=\"%d\"" $level -}}
25{{- $finalContent := replace $content $targetStr $replacementStr -}}
26
27<div x-data="ksAccordion({{ $jsMultiple }})" @keydown="handleKeydown" class="ks-accordion">
28  {{- $finalContent | safeHTML -}}
29</div>

Child Partial

Go/HTML template layouts/_partials/ui/accordion-item.html
 1{{- $title := .title | default "Accordion Item" -}}
 2{{- $icon := .icon | default "accordion/chevron-down" -}}
 3
 4{{- $uniqueID := substr (md5 (printf "%s%p" $title .)) 0 8 -}}
 5{{- $uid := printf "ks-accordion-%s" $uniqueID -}}
 6
 7{{- $finalContent := "" -}}
 8{{- if .content }}
 9  {{- /* Called as a shortcode */}}
10  {{- $finalContent = .content | strings.TrimSpace | markdownify -}}
11{{- else }}
12  {{- /* Called as a Decorator (HTML content) */}}
13  {{- $finalContent = (inner .) | strings.TrimSpace | safeHTML -}}
14{{- end }}
15
16
17<div x-data="{ id: '{{ $uid }}' }">
18  <div role="heading" data-ks-level-placeholder>
19    <button
20      type="button"
21      data-accordion-trigger
22      @click="toggle(id)"
23      :aria-expanded="isOpen(id)"
24      :aria-controls="'panel-' + id"
25      :id="'tab-' + id"
26    >
27      {{- $title -}}
28      {{- if $icon }}
29        {{- if templates.Exists "_partials/ui/icon.html" -}}
30          {{- partial "ui/icon" (dict "name" $icon) }}
31        {{- else }}
32          {{- warnf "Keystone UI [Accordion]: Icon partial not found. Using fallback icon." -}}
33          <svg
34            xmlns="http://www.w3.org/2000/svg"
35            viewBox="0 0 24 24"
36            fill="none"
37            stroke="currentColor"
38            stroke-width="2"
39            stroke-linecap="round"
40            stroke-linejoin="round"
41          >
42            <polyline points="6 9 12 15 18 9"></polyline>
43          </svg>
44        {{- end }}
45      {{- end }}
46    </button>
47  </div>
48  <div :id="'panel-' + id" role="region" :aria-labelledby="'tab-' + id" x-show="isOpen(id)" x-collapse x-cloak>
49    <div class="ks-panel">
50      {{- $finalContent -}}
51    </div>
52  </div>
53</div>

Hugo Shortcodes

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

Create the parent ui/accordion and child ui/accordion-item shortcodes.

Parent Shortcode

Go/HTML template layouts/_shortcodes/ui/accordion.html
 1{{- $path := "_partials/ui/accordion.html" -}}
 2{{- $partial := "ui/accordion" -}}
 3{{- if templates.Exists $path }}
 4  {{- partial $partial (dict
 5    "multiple" (.Get "multiple")
 6    "level"    (.Get "level")
 7    "content"  .Inner
 8    )
 9  -}}
10{{- else }}
11  {{- $errorMsg := printf "Keystone UI: Missing partial `%s`." $path -}}
12  {{- if hugo.IsProduction }}
13    {{- errorf $errorMsg -}}
14  {{- else }}
15    {{- warnf $errorMsg -}}
16  {{- end }}
17{{- end }}

Child Shortcode

Go/HTML template layouts/_shortcodes/ui/accordion-item.html
 1{{- $path := "_partials/ui/accordion-item.html" -}}
 2{{- $partial := "ui/accordion-item" -}}
 3{{- if templates.Exists $path -}}
 4  {{- partial $partial (dict
 5    "title"   (.Get "title")
 6    "icon"    (.Get "icon")
 7    "content" .Inner
 8    )
 9  }}
10{{- else }}
11  {{- $errorMsg := printf "Keystone UI: Missing partial `%s`." $path -}}
12  {{- if hugo.IsProduction }}
13    {{- errorf $errorMsg -}}
14  {{- else }}
15    {{- warnf $errorMsg -}}
16  {{- end }}
17{{- end }}

CSS Styling

Create the CSS component:

Tailwind CSS assets/css/components/_accordion.css
 1@layer components {
 2  /* Accordion */
 3  .ks-accordion {
 4    @apply w-full max-w-lg;
 5    @apply text-sm;
 6    @apply divide-border divide-y overflow-hidden;
 7
 8    button {
 9      @apply flex items-center justify-between gap-6;
10      @apply w-full px-4 py-3;
11      @apply hover:bg-muted aria-expanded:bg-muted;
12      @apply text-left font-medium;
13      @apply rounded-sm border border-transparent;
14      @apply transition-all duration-300;
15      @apply [&[aria-expanded=true]>svg]:-rotate-180;
16      @apply focus-visible:border-ring focus-visible:ring-ring/50 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-inset;
17    }
18    svg {
19      @apply size-4 shrink-0 transition-transform duration-300;
20    }
21    .ks-panel {
22      @apply px-4 py-3 [&_p]:mb-[2em] [&_p:last-child]:mb-[1em];
23      @apply text-muted-foreground;
24      @apply [&_a]:underline [&_a]:underline-offset-2;
25    }
26}

Import it in your main Tailwind file:

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

Usage

Using Partials (Templates)

HTML layouts/home.html
1<h2>Accordion Demo</h2>
2{{- with partial "ui/accordion" (dict "level" 3) }}
3  {{- with partial "ui/accordion-item" (dict "title" "Is it accessible?") }}
4    <p>Yes, it follows the WAI-ARIA pattern strictly.</p>
5  {{- end }}
6{{- end }}

Using Shortcodes (Markdown)

Markdown content/example.md
1## Accordion Demo
2{{< ui/accordion level="3" >}}
3
4{{< ui/accordion-item title="Is it accessible?" >}}
5
6Yes, it follows the WAI-ARIA pattern strictly.
7{{< /ui/accordion-item >}}
8
9{{< /ui/accordion >}}

Accessibility

The component follows the WAI-ARIA Accordion Pattern(Opens in a new tab) and meets WCAG 2.2 standards:

  1. Semantic Hierarchy: Keystone allows you to pass a level prop, which is injected as aria-level within a role="heading".
  2. Keyboard Traversal: Full support for arrow keys ( ) to move focus between triggers, and HOME / END to jump to the start/end of the list.
  3. State Disclosure: Uses aria-expanded to communicate the toggle state and aria-controls to link triggers to their respective panels.
  4. Region Identification: Each panel uses role="region" and is programmatically labelled by its trigger via aria-labelledby.