Back to devlogs
Laravel · Blade · Tailwindcss

LaralCN-UI 0.3.0: components that carry their own JavaScript

/ / 4 min read

Every interactive component in the registry used Alpine.js, which meant copying one Blade file into a fresh Laravel app was never actually one step. 0.3.0 removes Alpine entirely: each component ships a small inline script in the same file, and several of them now need no script at all. Plus the four traps I hit doing it, including a modal that opened invisible.

The LaralCN-UI social card: Plate I, the twenty-five registry primitives drawn as numbered specimens on a dark taxonomic plate.
On this page

LaralCN-UI is a copy-and-own component system for Blade. There is no runtime package: php artisan ui:add dialog writes a .blade.php file into your source tree and you own it from that moment.

That promise had a hole in it. Every interactive component was built on Alpine.js, so the honest instruction was never "copy this file". It was "copy this file, then npm install alpinejs, then @alpinejs/focus for the dialog, then @alpinejs/collapse for the accordion, then wire Alpine into your bundle". On a fresh laravel new that is a lot of work to place a dropdown.

0.3.0 removes Alpine from the entire registry. dependencies.js is now empty for all 25 components and all 7 blocks, and ui:add never prints an npm install line.

The platform first, then a script

The first pass was not about rewriting Alpine into vanilla JavaScript. It was about asking which components needed JavaScript at all.

tooltip ships zero. Hover and keyboard focus are both CSS:

<span class="group relative inline-flex">
    {{ $slot }}
    <span role="tooltip"
        class="invisible opacity-0 group-hover:visible group-hover:opacity-100
               group-focus-within:visible group-focus-within:opacity-100">
        {{ $text }}
    </span>
</span>

switch ships zero as well. It used to be a <button role="switch"> with Alpine holding the state, which meant it did not submit with a form and you had to add a hidden input. It is now a real checkbox:

<label class="... has-[:checked]:bg-primary">
    <input type="checkbox" role="switch" class="peer sr-only" name="{{ $name }}" @checked($checked) />
    <span class="... peer-checked:translate-x-[calc(100%-2px)]"></span>
</label>

Keyboard support, form submission, and the checked state all come from the browser. The component got shorter and gained a feature.

dialog, sheet, and the sidebar's mobile panel now use the native <dialog> element with showModal(). That single call provides the top layer, a focus trap, Escape to close, and an inert page behind. The Alpine versions were reimplementing all four, and x-trap.noscroll.inert was the reason @alpinejs/focus was a dependency at all.

What a component script looks like

The rest do need a script, and the rule I settled on is that it lives in the same file as the markup:

<div class="group/dropdown relative inline-block" data-ui-dropdown data-state="closed">
    <div data-ui-dropdown-trigger aria-expanded="false">{{ $trigger }}</div>
    <div data-ui-dropdown-menu role="menu" class="hidden ...">{{ $slot }}</div>
</div>

@once
    <script>
        (function() {
            if (window.__laralcnDropdownMenu) return;
            window.__laralcnDropdownMenu = true;

            document.addEventListener('click', function(event) {
                var trigger = event.target.closest('[data-ui-dropdown-trigger]');
                // ...
            });
        })();
    </script>
@endonce

Three decisions are doing the work there.

@once means the script renders one time no matter how many dropdowns are on the page. Listeners are delegated from document rather than bound per instance, so markup added after page load keeps working. And state that other markup needs to react to goes on the root as data-state, next to a named Tailwind group, so consumers style the open state in CSS instead of reaching into a JavaScript scope:

<svg class="transition-transform group-data-[state=open]/dropdown:rotate-180">

That last one replaced the old pattern, where a block would write x-bind:class="open ? 'rotate-180' : ''" and quietly depend on a variable defined inside a different file. The CSS version has no such coupling.

Four things that went wrong

None of this was found by reading the code. All four came from clicking through the site in a browser.

A sheet opened from a navbar rendered nothing. showModal() puts an element in the top layer, but it does not rescue an ancestor with display: none, and every navbar wraps its mobile menu in lg:hidden. Alpine had been papering over this with x-teleport="body". Both dialog and sheet now move themselves to the body before opening.

The open state was being set inside requestAnimationFrame, which does not run in a background tab. A panel could therefore open, lock page scrolling, and sit transformed off-screen with nothing visible. It is now a forced reflow in the same tick, which never depends on the tab being painted.

The sidebar renders its slot twice, once for the desktop panel and once for the mobile one. @once cannot help there, because the slot is rendered to a string and echoed twice, so a nested component shipped its script twice and every click fired the handler twice, cancelling itself out. That is what the window.__laralcn* flag in the snippet above is really for.

The fourth one cost me a confusing hour. Blade compiles component tags anywhere in a file, including inside a <script> block and inside a // comment. A comment I wrote explaining the guard mentioned a tag in angle brackets, and it compiled into a real component render with no closing tag. The test suite caught it as "unexpected end of file, expecting endif".

What it costs

The tabs component gained arrow, Home, and End key support, which the Alpine version never had. Accordion and collapsible keep their slide animation through the Web Animations API, with a prefers-reduced-motion check.

The honest trade is that a page with eight different interactive components now carries eight small scripts instead of one shared Alpine bundle. Uncompressed that is a few kilobytes against Alpine's 40 or so, and the components stay self-contained, which is the whole premise of the project.

Where it is

composer require --dev safi/laralcn-ui, then php artisan ui:init and php artisan ui:add button. The registry is 25 components and 7 blocks, Tailwind v4 only, MIT.

Docs are at laralcn-ui.abdulkadersafi.com and the code is on GitHub. The authoring standard in docs/AUTHORING.md now carries the script rules, including both Blade traps above, so the next component does not rediscover them.

Next post covers the blocks, which had a different problem: installing a navbar gave you a navbar and an entire demo page you did not ask for.

FAQ

Frequently asked

Because the project is copy-and-own. The promise is that you copy one .blade.php file into your app and it works. With Alpine that was never true: you also had to install alpinejs, plus @alpinejs/focus for the dialog and @alpinejs/collapse for the accordion, and wire Alpine into your bundle. On a fresh laravel new that is a lot of setup to place a dropdown. Now dependencies.js is empty for every component and ui:add never prints an npm install line.

Yes. Accordion and collapsible use the Web Animations API for the height slide, gated behind a prefers-reduced-motion check. Dialog and sheet transition their overlay and panel with CSS, keyed off a data-state attribute on the root. The one thing that changed is that the state is set with a forced reflow rather than requestAnimationFrame, because rAF does not run in a background tab and a panel could otherwise open off-screen.

The script is wrapped in Blade's @once directive so it renders a single time per page, and it opens with a window.__laralcn* flag guard as a second line of defence. Listeners are delegated from document and find their elements through data-ui-* attributes, so one listener set serves every instance on the page and keeps working for markup added after load.

Following along? Start a project.

Start a conversation →