<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.
<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.
<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?
Which networks are supported?
Why was my transaction declined?
Show code Hide code
<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?
Do you offer refunds?
How do I close my account?
Show code Hide code
<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
Security
Show code Hide code
<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 Hide code
<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
Show code Hide code
<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>
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
@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
@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
@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
// 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();