schaefersoft/laravel-headless-ui
Unstyled, accessible Laravel Blade UI components for building data-driven interfaces with traditional Blade. No extra JavaScript dependencies, performance-focused and easy to customize with your own CSS or utility classes. Requires PHP 8.2+ and Laravel 10–13.
Requirements: PHP 8.2+, Laravel 10–13. The service provider auto-discovers, so there is no config/app.php step and no publish step.
composer require schaefersoft/laravel-headless-ui
Then wire up both assets — this package is "no JS dependencies", not "no JS". The components need hui.js for the interaction/ARIA behaviour, and hui.css for the state-driven show/hide rules.
/* resources/css/app.css */
@import '../../vendor/schaefersoft/laravel-headless-ui/resources/css/hui.css' layer(base);
/* drop layer(base) if you are not on Tailwind v4 */
// resources/js/app.js
import '../../vendor/schaefersoft/laravel-headless-ui/dist/js/hui.js'
Use resources/js/hui.ts only if you already have a TypeScript pipeline; the prebuilt dist/js/hui.js is the recommended path and avoids adding vite TS config just for one dependency.
Where to look first
docs/<component>.md in the vendor directory — one file per component (avatar, dropdown, dialog, disclosure, flyout, range-slider, tabs, toggle, tooltip), each with the markup contract. Read the dropdown and dialog docs before anything else; they establish the trigger/panel composition pattern the rest follow.resources/views/components/ — the actual Blade class list and props for each subcomponent (maxWidth, position, open/opened, disabled, closeOnClick, closeOnBgClick, overFlow all come from documented props).resources/js/hui.ts — the only place that defines which data-* attributes and ARIA wiring you can style against. Skim it once; you will refer back to it every time a state style "doesn't apply".First use case: a single dropdown in an existing authenticated layout. It exercises everything a real integration needs — composing root/trigger/panel subcomponents, styling via utility classes, and confirming the JS bundle resolves through Vite. Get that one component visually correct before adding dialogs, tabs or flyouts to a live page.
Compose subcomponents, don't look for a monolithic tag. Every component ships as an x-hui:: root plus named parts (trigger/button, panel, item, backdrop, thumb, etc.). You are responsible for the markup between them and for all classes. Treat it like a Blade-native Radix: the structure is the API.
{{-- shape, not copy-paste — confirm exact subcomponent names in docs/dropdown.md --}}
<x-hui::dropdown>
<x-hui::dropdown.button class="rounded px-3 py-2 hover:bg-gray-100">
Actions
</x-hui::dropdown.button>
<x-hui::dropdown.panel class="absolute z-20 mt-1 rounded border bg-white shadow">
@foreach ($actions as $action)
<x-hui::dropdown.item :href="route('tasks.show', $action)" class="block px-3 py-2 text-sm">
{{ $action->name }}
</x-hui::dropdown.item>
@endforeach
</x-hui::dropdown.panel>
</x-hui::dropdown>
Build one design-system wrapper per component. Because the package is unstyled, your app will accumulate long class strings at every call site. Add your own thin wrappers (resources/views/components/app/dropdown.blade.php) that forward $attributes and hardcode your tokens. That keeps the vendor component swappable and means a redesign touches one file, not forty.
Style state through data attributes, not through class toggling. The JS writes state onto the root/parts (data-open, data-active, data-disabled, aria-expanded, aria-selected). Write your state CSS once against those attributes:
.hui-panel { display: none; }
[data-open] .hui-panel { display: block; }
[data-disabled] { opacity: .5; pointer-events: none; }
In Tailwind, this maps to data-[open]:block style variants — but only if the attribute is on the element that also carries your utility classes. Verify per component.
Server-driven content is the whole point. Populate dropdown items, tabs and flyout links from Eloquent collections and gate them with @can in the Blade loop. Dialogs and flyouts can be rendered conditionally per page rather than globally, which keeps initial HTML small and avoids a stack of hidden modals on every route.
Forms: add your own inputs. The toggle and range slider are visual control shells; they do not replace native form semantics. Include a hidden or visually-hidden input with name=/value= inside the control and keep it in sync, and confirm in the browser that a disabled control does not submit a stale value. For Livewire, drive that hidden input with wire:model and wrap the component in wire:ignore so DOM morphing does not strip the JS-owned attributes.
Dialog placement: render dialogs at the end of the layout (after the main content, before </body>), never inside a transform, overflow: hidden, or low z-index container. Use overflow, maxWidth, closeOnClick, closeOnBgClick rather than hand-rolling a backdrop — the package exposes a dedicated modal backdrop component since 1.2.0.
Flyout as navigation: the inline sidebar variant is rendered visible before JS runs (fixed in 1.5.2), so use that mode for admin sidebars where you cannot tolerate a flash of hidden nav. Overlay mode is for narrow viewports.
Progressive enhancement where it matters: the panel content is real Blade HTML, so it is indexable and present in the source. Keep the content server-rendered; do not fetch it over the wire just because the component is "headless".
Testing: feature tests can assertSee the rendered ARIA attributes and state — cheap regression protection for markup changes on upgrade. Anything keyboard-driven (arrow keys in tabs/dropdowns, Esc on tooltips, focus trap in dialogs) needs a real browser test (Dusk/Playwright); assert on the resulting focus target and open state, not on pixel classes.
vendor/. Vite must be able to read them. In Docker or CI where npm ci runs before composer install, the CSS/JS import silently fails or the build breaks with a resolution error. Install PHP deps first, or add the vendor directory to server.fs.allow in vite.config.js. After a composer update that bumps the package, restart the dev server — Vite caches the resolved module graph.@import ... layer(base) on Tailwind v4 so preflight and the component reset do not fight; on v3 the plain @import is what you want. Wrong layer ordering shows up as panels that render open on first paint, then snap shut.hui.js you get static markup: nothing toggles, aria-expanded never updates, Esc does nothing. The first debugging step for "it doesn't open" is always: check the browser console and confirm the import made it into the bundle output.data-active duplicate typo fix, a tablist → sentence-case rename, and a reverted value property on toggle. Pin an exact version in composer.json (this is a 1.x package with active API movement) and grep your stylesheets and Blade overrides for data-* attributes on every upgrade.data-open, aria-expanded, or aria-controls yourself will collide with the JS and produce stuck-open panels or duplicated toggling. If you need an initially-open state, use the documented prop (opened-by-default support landed in 1.1.0), not an attribute on the tag.$attributes merging on your wrappers. When you build an x-app::dropdown wrapper, spread {{ $attributes }} on the root — but be aware that class will merge while other attributes can override. Never let a call site inject data-* keys; restrict what you forward.keydown listener for a command palette or search shortcut will fire while a dialog is open. Gate your handlers on the presence of an open dialog or check event.defaultPrevented, otherwise Esc closes two things at once.x-data on the same markup — two systems writing the same attributes is an unfixable bug. After a Livewire navigation or morph, components rendered inside the morphed region lose behaviour: wire:ignore them, or re-render the whole block with wire:key so the JS re-binds.resources/js/hui.ts. If the attribute changes but nothing visually happens, the bug is in your CSS selector specificity or a missing class on the panel element, not in the package.vendor/schaefersoft/laravel-headless-ui/resources/ when behaviour is undocumented, and expect to send upstream PRs for real bugs. The upside is the whole surface area is small enough to read in an afternoon — do that before committing, because the docs are per-component and thinner than the code.How can I help you explore Laravel packages today?