Skip to content
LarawellUi

<x-widget.accordion>

Accordion

Disclosure panels on <details>, as tinted panels, a bordered list or cards. Panels sharing a name open one at a time. The menu variant groups links in a sidebar that scrolls on its own and opens at the current page. Works inside Livewire components: a render updates what's inside and leaves panels open or shut as they were.

php artisan larawell:add accordion
Also adds
Icon

Usage

Remember

remember keeps a menu where it was scrolled from page to page, so the link you click stays under the pointer: the menu is put back before the next page is painted, and the current item is only centred when it would otherwise be out of view. That takes a small inline script, which carries your CSP nonce if you set one with Vite::useCspNonce(); under a CSP that blocks it, the menu still opens with the current item centred. Give each menu its own key ("docs" here), or write just remember to use its label.

Blade
<x-widget.accordion.menu label="Documentation" remember="docs" class="max-h-[calc(100dvh-8rem)]">
    <x-widget.accordion.menu-item href="{{ route('docs.intro') }}" :current="request()->routeIs('docs.intro')">Introduction</x-widget.accordion.menu-item>

    <x-widget.accordion variant="menu" title="Billing">
        <x-widget.accordion.menu-item href="{{ route('docs.refunds') }}" :current="request()->routeIs('docs.refunds')">Refunds</x-widget.accordion.menu-item>
    </x-widget.accordion>
</x-widget.accordion.menu>

Livewire

Inside a Livewire component, a render updates what's in each panel and leaves it open or shut as the person left it, the closing animation and the one-at-a-time group included. So :open only sets how a panel starts; changing it in PHP later doesn't open or close it. In a menu, the group someone opened and the #section link they clicked stay as they were too. Give panels built in a loop a wire:key.

Blade
<div class="flex flex-col gap-2">
    @foreach ($orders as $order)
        <x-widget.accordion wire:key="order-{{ $order->id }}" name="orders" :title="'Order '.$order->number">
            {{ $order->status }}: {{ $order->total }}

            <button type="button" wire:click="refund({{ $order->id }})">Refund</button>
        </x-widget.accordion>
    @endforeach
</div>

Examples

Faq

Panels that share a name close each other, so only one answer is open at a time.

How long does a withdrawal take?
Most withdrawals complete within 30 minutes. Network congestion can add delays.
Which networks are supported?
TRC20, ERC20 and BEP20. Always check the network before sending funds.
Why was my transaction declined?
Common reasons are an incorrect address, insufficient balance or a daily limit.
Show code
Blade
<div class="flex flex-col gap-2">
    <x-widget.accordion name="faq" title="How long does a withdrawal take?" open>
        Most withdrawals complete within 30 minutes. Network congestion can add delays.
    </x-widget.accordion>
    <x-widget.accordion name="faq" title="Which networks are supported?">
        TRC20, ERC20 and BEP20. Always check the network before sending funds.
    </x-widget.accordion>
    <x-widget.accordion name="faq" title="Why was my transaction declined?">
        Common reasons are an incorrect address, insufficient balance or a daily limit.
    </x-widget.accordion>
</div>

Bordered

variant="bordered": a divided list with no panels, the usual shape for an FAQ.

Can I change my plan later?
Yes. Upgrades apply straight away; downgrades take effect at the next billing date.
Do you offer refunds?
Within 14 days of payment, in full, no questions asked.
How do I close my account?
Go to Settings, then Account, then Close account. Your data is deleted after 30 days.
Show code
Blade
<div>
    <x-widget.accordion variant="bordered" name="help" title="Can I change my plan later?">
        Yes. Upgrades apply straight away; downgrades take effect at the next billing date.
    </x-widget.accordion>
    <x-widget.accordion variant="bordered" name="help" title="Do you offer refunds?">
        Within 14 days of payment, in full, no questions asked.
    </x-widget.accordion>
    <x-widget.accordion variant="bordered" name="help" title="How do I close my account?">
        Go to Settings, then Account, then Close account. Your data is deleted after 30 days.
    </x-widget.accordion>
</div>

Cards

variant="card": each item is its own card, which suits settings and grouped forms.

Notifications
Email me when a withdrawal completes, fails, or needs another check.
Security
Two-factor authentication is on. You signed in from 2 devices this month.
Show code
Blade
<div class="flex flex-col gap-3">
    <x-widget.accordion variant="card" title="Notifications" open>
        Email me when a withdrawal completes, fails, or needs another check.
    </x-widget.accordion>
    <x-widget.accordion variant="card" title="Security">
        Two-factor authentication is on. You signed in from 2 devices this month.
    </x-widget.accordion>
</div>

Flush

flush drops the body's padding and text style, so content like this list runs edge to edge.

Fees by network
TRC20
1.00 USDT
ERC20
4.50 USDT
BEP20
0.80 USDT
Show code
Blade
<x-widget.accordion variant="card" title="Fees by network" open flush>
    <dl class="divide-line border-line divide-y border-t text-sm">
        @foreach (['TRC20' => '1.00 USDT', 'ERC20' => '4.50 USDT', 'BEP20' => '0.80 USDT'] as $network => $fee)
            <div class="flex justify-between px-5 py-3">
                <dt class="text-foreground/75">{{ $network }}</dt>
                <dd class="font-medium">{{ $fee }}</dd>
            </div>
        @endforeach
    </dl>
</x-widget.accordion>

Custom header

Pass a header slot instead of a title to put anything in the summary row.

Account limits Verified
Daily withdrawal limit: 50,000 USDT. Monthly: 1,000,000 USDT.
Show code
Blade
<x-widget.accordion>
    <x-slot:header>
        <span class="flex items-center gap-2">
            Account limits
            <span class="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">Verified</span>
        </span>
    </x-slot:header>
    Daily withdrawal limit: 50,000 USDT. Monthly: 1,000,000 USDT.
</x-widget.accordion>

Menu

variant="menu" inside <x-widget.accordion.menu>: a sidebar that scrolls on its own and opens already scrolled to the current page, with no flash of the top of the list first. Groups start open; :open="false" closes one. With #section links like these, the highlight follows the link you click and the URL's #hash.

Show code
Blade
<x-widget.accordion.menu label="Documentation" class="max-h-72 max-w-64">
    <x-widget.accordion.menu-item href="#introduction">Introduction</x-widget.accordion.menu-item>
    <x-widget.accordion.menu-item href="#installation">Installation</x-widget.accordion.menu-item>

    <x-widget.accordion variant="menu" title="Getting started">
        <x-widget.accordion.menu-item href="#first-steps">First steps</x-widget.accordion.menu-item>
        <x-widget.accordion.menu-item href="#configuration">Configuration</x-widget.accordion.menu-item>
        <x-widget.accordion.menu-item href="#directory-structure">Directory structure</x-widget.accordion.menu-item>
    </x-widget.accordion>

    <x-widget.accordion variant="menu" title="Billing">
        <x-widget.accordion.menu-item href="#plans">Plans</x-widget.accordion.menu-item>
        <x-widget.accordion.menu-item href="#invoices">Invoices</x-widget.accordion.menu-item>
        <x-widget.accordion.menu-item href="#refunds" current>Refunds</x-widget.accordion.menu-item>
        <x-widget.accordion.menu-item href="#tax">Tax</x-widget.accordion.menu-item>
    </x-widget.accordion>

    <x-widget.accordion variant="menu" title="Account" :open="false">
        <x-widget.accordion.menu-item href="#profile">Profile</x-widget.accordion.menu-item>
        <x-widget.accordion.menu-item href="#security">Security</x-widget.accordion.menu-item>
    </x-widget.accordion>

    <x-widget.accordion.menu-item href="#changelog">Changelog</x-widget.accordion.menu-item>
</x-widget.accordion.menu>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.accordion>

Prop Default Description
title null The header text, or use the header slot for markup.
open null Starts open. A menu group is open unless set to false, so it paints the same on every page.
name null Items sharing a name open one at a time.
variant 'default' default, bordered, card, or menu (a group of links inside an accordion.menu).
flush false No padding or text style on the body, for content that runs edge to edge.

<x-widget.accordion.menu>

Prop Default Description
label Required Names the navigation for screen readers ("Components", "Settings").
remember null Keep where it was scrolled from page to page, under this key ("docs"), or the label's with just `remember`.

<x-widget.accordion.menu-item>

Prop Default Description
href Required Where the link goes: another page, or a #section of this one.
current false The page you're on: highlighted, announced as the current page, and where the menu opens scrolled to. For #section links, resources/js/widget/accordion moves it as they're clicked and as the URL's #hash changes.

Source

What larawell:add accordion writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/accordion/index.blade.php Show
index.blade.php
@props([
    // The header text, or use the header slot for markup.
    'title' => null,
    // Starts open. A menu group is open unless set to false, so it paints the same on every page.
    'open' => null,
    // Items sharing a name open one at a time.
    'name' => null,
    // default, bordered, card, or menu (a group of links inside an accordion.menu).
    'variant' => 'default',
    // No padding or text style on the body, for content that runs edge to edge.
    'flush' => false,
])

@php
    // default: tinted panel while open. bordered: a divided list, for FAQs. card: each item its own card.
    $variants = [
        'default' => [
            'item' => 'rounded-2xl transition-colors duration-300 open:bg-field data-closing:bg-transparent motion-reduce:transition-none',
            'summary' => 'rounded-2xl px-5 py-4 hover:bg-field',
            'body' => 'px-5 pb-4',
        ],
        'bordered' => [
            'item' => 'border-line border-b first:border-t',
            'summary' => 'px-1 py-5 hover:text-primary',
            'body' => 'px-1 pb-5',
        ],
        'card' => [
            'item' => 'border-line bg-surface rounded-2xl border shadow-sm',
            'summary' => 'rounded-2xl px-5 py-4 hover:bg-field/60 group-open/accordion:rounded-b-none',
            'body' => 'px-5 pb-5',
        ],
        // A heading over a group of links in a sidebar: compact, and the links indented under a rule.
        'menu' => [
            'item' => '',
            'summary' => 'rounded-lg px-3 py-1.5 text-sm text-foreground/70 hover:bg-field hover:text-foreground',
            'body' => 'border-line ms-3 flex flex-col gap-0.5 border-s ps-2 pt-0.5 pb-1',
        ],
    ];
    $menu = $variant === 'menu';
    $style = $variants[$variant] ?? $variants['default'];
    $open ??= $menu;
    $chevron = [$menu ? 'size-4' : 'size-6', '-rotate-90 transition-transform duration-300 group-open/accordion:rotate-0 group-data-closing/accordion:-rotate-90 rtl:rotate-90 rtl:group-open/accordion:rotate-0 rtl:group-data-closing/accordion:rotate-90 motion-reduce:transition-none'];
@endphp

{{--
    Native <details>: works with no JS (keyboard, screen readers, find-in-page all built in).
    The JS adds the open/close animation and takes over the `name` group ("one open at a time")
    so the item that closes animates too, instead of snapping shut.
--}}
@if ($menu)
    <li class="list-none">
@endif
{{-- wire:ignore.self: whether it's open, and the attributes the script moves (name, data-closing), are the person's once
     the page is up; a Livewire render would put back the server's and snap panels open or shut. What's inside still updates.
     So open only sets how a panel starts. Outside Livewire the attribute does nothing. --}}
<details
    data-accordion
    wire:ignore.self
    @if ($open) open @endif
    @if ($name) name="{{ $name }}" @endif
    {{ $attributes->class(['group/accordion w-full', $style['item']]) }}
>
    <summary @class([
        'focus-visible:ring-primary flex cursor-pointer list-none items-center justify-between gap-4 text-start outline-none select-none focus-visible:ring-2 [&::-webkit-details-marker]:hidden',
        // A menu group's heading has the same colour and weight as the links around it; its chevron marks it as a group.
        'text-foreground font-medium' => ! $menu,
        $style['summary'],
    ])>
        <span>{{ $header ?? $title }}</span>
        {{-- Closed, the chevron points to the end of the line (right, or left in right-to-left pages); open, down. --}}
        <x-widget.icon name="chevron-down" :class="implode(' ', $chevron)" />
    </summary>

    {{-- Padding lives on the inner div: the animated outer one must be able to reach 0 height.
         flush drops the padding and text style, for content that should run edge to edge (a table, a form). --}}
    <div data-accordion-content>
        @if ($menu)
            <ul class="{{ $style['body'] }}">
                {{ $slot }}
            </ul>
        @else
            <div @class(['text-foreground/75 text-sm '.$style['body'] => ! $flush])>
                {{ $slot }}
            </div>
        @endif
    </div>
</details>
@if ($menu)
    </li>
@endif
resources/views/components/widget/accordion/menu-item.blade.php Show
menu-item.blade.php
@props([
    // Where the link goes: another page, or a #section of this one.
    'href',
    // The page you're on: highlighted, announced as the current page, and where the menu opens scrolled to.
    // For #section links, resources/js/widget/accordion moves it as they're clicked and as the URL's #hash changes.
    'current' => false,
])

<li class="list-none">
    {{-- Styled from aria-current, not from the prop, so the highlight follows the script when it moves aria-current.
         scroll-initial-target: the menu starts scrolled to the server's current link, centred, before the first paint. --}}
    {{-- wire:ignore.self: the script moves aria-current to the #section clicked and drops scroll-initial-target after load;
         a Livewire render would put both back. The text inside still updates. --}}
    <a
        wire:ignore.self
        href="{{ $href }}"
        @if ($current) aria-current="page" @endif
        {{ $attributes->class([
            'focus-visible:ring-primary block rounded-lg px-3 py-1.5 outline-none focus-visible:ring-2',
            'text-foreground/70 hover:bg-field hover:text-foreground',
            'aria-[current=page]:bg-primary aria-[current=page]:text-on-primary aria-[current=page]:font-medium aria-[current=page]:hover:bg-primary aria-[current=page]:hover:text-on-primary',
            'snap-center [scroll-initial-target:nearest]' => $current,
        ]) }}
    >{{ $slot }}</a>
</li>
resources/views/components/widget/accordion/menu.blade.php Show
menu.blade.php
@props([
    // Names the navigation for screen readers ("Components", "Settings").
    'label',
    // Keep where it was scrolled from page to page, under this key ("docs"), or the label's with just `remember`.
    'remember' => null,
])

@php
    $key = match (true) {
        $remember === true => 'larawellui:accordion-menu:'.\Illuminate\Support\Str::slug($label),
        is_string($remember) && $remember !== '' => 'larawellui:accordion-menu:'.$remember,
        default => null,
    };
@endphp

{{--
    A sidebar menu that scrolls on its own: links (accordion.menu-item) and groups of them (an accordion with
    variant="menu"). Give it a height, e.g. class="max-h-96", or one that fills the window.

    It paints already scrolled to the current page, with no script: the current item is the container's
    scroll-initial-target, centred by its snap alignment, so there's no flash of the top of the list first.
    Groups are open from the server for the same reason. Where the browser doesn't support scroll-initial-target
    yet, resources/js/widget/accordion brings the current item into view after the page loads.

    overflow-anchor: none, so a group opening or closing leaves the heading you clicked where it is. With anchoring on, the
    browser keeps an item inside the animating group steady instead, and the list slides under the pointer.
--}}
<nav data-accordion-menu @if ($key) data-accordion-menu-remember="{{ $key }}" @endif aria-label="{{ $label }}" {{ $attributes->class(['relative overflow-y-auto overscroll-contain pe-3 [overflow-anchor:none] [scrollbar-width:thin]']) }}>
    {{-- wire:ignore.self: the script keeps room at the end of the list as a group closes (an inline padding), which a
         Livewire render would drop, sliding the list. --}}
    <ul wire:ignore.self class="flex flex-col gap-0.5 text-sm">
        {{ $slot }}
    </ul>
</nav>
@if ($key)
    {{-- remember: put the menu back where it was left (resources/js/widget/accordion saves that on leaving the page),
         unless that leaves the current item out of view. Inline and synchronous on purpose, so it runs the moment the
         menu is parsed and the first paint already shows it there; the deferred bundle would run after that paint and
         jump. It carries the app's CSP nonce, if there is one (Vite::useCspNonce()). Under a CSP that blocks it, the
         menu still opens with the current item centred, only without remembering. --}}
    <script @if ($nonce = \Illuminate\Support\Facades\Vite::cspNonce()) nonce="{{ $nonce }}" @endif>
        (() => {
            const menu = document.currentScript.previousElementSibling;
            let saved = 0;
            try {
                saved = Number(sessionStorage.getItem(@js($key))) || 0;
            } catch {
                return;
            }
            if (saved === 0) {
                return;
            }
            menu.scrollTop = saved;
            const current = menu.querySelector('[aria-current="page"]');
            if (current && (current.offsetTop < menu.scrollTop || current.offsetTop + current.offsetHeight > menu.scrollTop + menu.clientHeight)) {
                menu.scrollTop = current.offsetTop - (menu.clientHeight - current.offsetHeight) / 2;
            }
        })();
    </script>
@endif
resources/js/widget/accordion/index.js Show
index.js
// Animates <x-widget.accordion> open and close. Plain <details> snaps; the CSS-only
// alternative (::details-content) isn't in every browser and can't animate an item that
// the browser closes for a `name` group. So the height is animated here, and the group
// is handled here too.

const DURATION = 300;
const EASING = 'ease-out';
const running = new WeakMap();

const content = (details) => details.querySelector(':scope > [data-accordion-content]');
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function animateHeight(details, from, to, onDone) {
    const panel = content(details);
    running.get(details)?.cancel();

    panel.style.overflow = 'hidden';
    const animation = panel.animate(
        [{ height: `${from}px`, opacity: from === 0 ? 0 : 1 }, { height: `${to}px`, opacity: to === 0 ? 0 : 1 }],
        { duration: reducedMotion() ? 0 : DURATION, easing: EASING },
    );
    running.set(details, animation);

    animation.onfinish = () => {
        panel.style.overflow = '';
        running.delete(details);
        onDone?.();
    };
    // A reversed click cancels mid-way; the next animation starts from the current height. That one has already
    // set overflow:hidden for itself by the time this runs, so only reset it if nothing replaced this animation.
    animation.oncancel = () => {
        if (running.get(details) === animation) {
            panel.style.overflow = '';
            running.delete(details);
        }
    };
}

// Current rendered height, including mid-animation, so reversing a toggle doesn't jump.
const currentHeight = (details) => (details.open ? content(details).getBoundingClientRect().height : 0);

// In a menu scrolled to its end, a closing group makes the list shorter than where it's scrolled to, so the browser
// would pull it back and the whole list would slide down. Keep room for that at the end instead: the heading stays put.
// The room only ever shrinks: as the menu is scrolled back up, and when a group opens. In a menu not near its end it's 0.
const menuList = (menu) => menu.querySelector(':scope > ul');

function keepRoom(menu, shrinkBy = 0) {
    const list = menuList(menu);
    const room = parseFloat(list.style.paddingBottom) || 0;
    const content = menu.scrollHeight - room - shrinkBy;
    const needed = Math.max(0, Math.ceil(menu.scrollTop + menu.clientHeight - content));
    // Grows only for a group about to close; anything else can only take room away.
    const next = shrinkBy > 0 ? Math.max(room, needed) : Math.min(room, needed);
    list.style.paddingBottom = next > 0 ? `${next}px` : '';
}

document.addEventListener('scroll', (event) => {
    if (event.target instanceof Element && event.target.matches('[data-accordion-menu]') && menuList(event.target)?.style.paddingBottom) {
        keepRoom(event.target);
    }
}, true);

function collapse(details) {
    if (!details.open || details.hasAttribute('data-closing')) {
        return;
    }
    const menu = details.closest('[data-accordion-menu]');
    if (menu) {
        keepRoom(menu, currentHeight(details));
    }
    // data-closing lets the tint and chevron animate alongside the height, while `open` stays set.
    details.setAttribute('data-closing', '');
    animateHeight(details, currentHeight(details), 0, () => {
        details.open = false;
        details.removeAttribute('data-closing');
    });
}

function expand(details) {
    const from = details.hasAttribute('data-closing') ? currentHeight(details) : 0;
    details.removeAttribute('data-closing');
    details.open = true;

    const group = details.dataset.accordionGroup;
    if (group) {
        document.querySelectorAll(`details[data-accordion-group="${CSS.escape(group)}"][open]`).forEach((other) => {
            if (other !== details) {
                collapse(other);
            }
        });
    }

    const menu = details.closest('[data-accordion-menu]');
    animateHeight(details, from, content(details).scrollHeight, menu ? () => keepRoom(menu) : undefined);
}

// The native `name` group would close siblings instantly, before they could animate, so move it to a
// data attribute and enforce "one open at a time" in expand() instead. Runs at load and again before every
// toggle, so accordions added later (fetched HTML, a table page swap) are covered too; the native name stays
// in the markup until then, so without JS the group still works.
function adoptGroups() {
    document.querySelectorAll('details[data-accordion][name]').forEach((details) => {
        details.dataset.accordionGroup = details.getAttribute('name');
        details.removeAttribute('name');
    });
}

document.addEventListener('click', (event) => {
    const summary = event.target.closest?.('details[data-accordion] > summary');
    if (!summary) {
        return;
    }
    event.preventDefault();
    adoptGroups();
    const details = summary.parentElement;
    details.open && !details.hasAttribute('data-closing') ? collapse(details) : expand(details);
});

adoptGroups();

// <x-widget.accordion.menu> paints scrolled to its current item with CSS alone (scroll-initial-target). Browsers without
// it yet open the menu at the top, so bring the current item into view here, centred; that runs after the first paint.
// Only when it's out of view: a menu with `remember` may already be back where it was left, and that should stay.
if (!CSS.supports('scroll-initial-target', 'nearest')) {
    document.querySelectorAll('[data-accordion-menu]').forEach((menu) => {
        const current = menu.querySelector('[aria-current="page"]');
        if (current && (current.offsetTop < menu.scrollTop || current.offsetTop + current.offsetHeight > menu.scrollTop + menu.clientHeight)) {
            menu.scrollTop = current.offsetTop - (menu.clientHeight - current.offsetHeight) / 2;
        }
    });
}

// scroll-initial-target has set where the menu is first painted, but Chrome keeps the menu pinned to it until something
// scrolls the menu, re-centring the current item whenever the list changes height: opening or closing a group would
// slide the whole list instead of only what's below the heading. Its work is done once the page has loaded, so drop it.
document.querySelectorAll('[data-accordion-menu] [aria-current="page"]').forEach((current) => current.classList.remove('[scroll-initial-target:nearest]'));

// remember: keep each such menu's scroll position for the next page (a link, reload, back). The inline script the
// menu renders puts it back before that page's first paint.
window.addEventListener('pagehide', () => {
    document.querySelectorAll('[data-accordion-menu-remember]').forEach((menu) => {
        try {
            sessionStorage.setItem(menu.dataset.accordionMenuRemember, String(menu.scrollTop));
        } catch {
            // Storage turned off: the menu simply opens at the current item next time.
        }
    });
});

// A menu of #section links (a table of contents) never reloads the page, so the server's current item would stay put
// while the URL moves on. Move aria-current, which the highlight is styled from, to the link for the URL's #hash: on
// load, on a click, and on back / forward. Links to other pages are left to the server.
const sectionLink = (link) => link.hash !== '' && link.origin === location.origin && link.pathname === location.pathname && link.search === location.search;

function markCurrent(menu, link) {
    menu.querySelectorAll('[aria-current="page"]').forEach((other) => other !== link && other.removeAttribute('aria-current'));
    link.setAttribute('aria-current', 'page');
}

function followHash() {
    if (location.hash === '') {
        return;
    }
    document.querySelectorAll('[data-accordion-menu]').forEach((menu) => {
        const link = [...menu.querySelectorAll('a[href]')].find((a) => sectionLink(a) && a.hash === location.hash);
        if (link) {
            markCurrent(menu, link);
        }
    });
}

document.addEventListener('click', (event) => {
    const link = event.target instanceof Element ? event.target.closest('[data-accordion-menu] a[href]') : null;
    if (link && sectionLink(link)) {
        markCurrent(link.closest('[data-accordion-menu]'), link);
    }
});
window.addEventListener('hashchange', followHash);
followHash();