Alpine.js directive for asChild-style composition: transfer attributes, classes, styles, and Alpine bindings from a wrapper to its first real child element — without an extra DOM node.
pnpm add @ailuracode/alpine-child @ailuracode/alpine-core @alpinejs/morph alpinejsimport Alpine from "alpinejs";
import morph from "@alpinejs/morph";
import { childPlugin } from "@ailuracode/alpine-child";
Alpine.plugin(morph);
Alpine.plugin(childPlugin());
Alpine.start();Unwrapping uses Alpine.morph() — register Morph before x-child.
<span
x-child
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium"
@click="console.log('button behavior')"
>
<a href="/docs">Docs</a>
</span>Result in the DOM:
<a
href="/docs"
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium"
>
Docs
</a>| React (Radix / shadcn) | Alpine (x-child) |
|---|---|
<Button asChild><a href="…"> |
<span x-child …><a href="…"> |
cloneElement merges props |
Directive merges attributes onto first child |
| No wrapper in React tree | Wrapper is removed from the live DOM |
x-child is useful for headless Alpine components and Blade/Laravel components that need button/link semantics without an extra <span> in the final markup.
| Modifier | Behavior |
|---|---|
| (none) | Merge class / style; copy other attributes only when missing on the child |
.merge |
Same as default (explicit) |
.replace |
Wrapper values win on conflicts (classes still merge) |
<div x-child.replace class="wrapper" aria-label="Wrapper">
<button class="child" aria-label="Child">Action</button>
</div>Merged: class, style
Copied when missing on child: aria-*, data-*, role, tabindex, @click, x-on:*, x-bind:*, :attr, etc.
Child wins by default: existing id, aria-*, data-*, and most attributes
Never copied: x-child, x-ignore, x-teleport, x-cloak, transition internals
Scope transfer: x-data, x-init, and x-ref move to the child when the child does not already define them
Declarative Alpine events on the wrapper (@click, @keydown.enter, x-on:click) are copied to the child before Alpine initializes the child, so handlers run on the real interactive element.
Programmatic listeners attached to the wrapper at runtime are not transferred.
{{-- resources/views/components/button.blade.php --}}
<span
x-child
{{ $attributes->merge(['class' => 'inline-flex rounded-md px-4 py-2']) }}
>
{{ $slot }}
</span><x-button>
<a href="{{ route('docs') }}">Docs</a>
</x-button>The anchor receives merged classes and any Alpine attributes you put on <x-button>.
- Requires
@alpinejs/morphregistered before this plugin. - Only the first element child is kept; text nodes and comments are skipped. Extra element siblings are discarded with the detached wrapper.
- Works best when the wrapper exists in static HTML/Blade before
Alpine.start(). Dynamically inserted trees are supported viaAlpine.initTree(). x-for/x-ifon the wrapper are not supported — usex-childon stable wrapper markup instead.- Nested
x-childon the same branch is not supported. - SSR: the wrapper is present in server HTML; after hydration the wrapper is replaced client-side.
This package registers a single directive:
x-childx-child.mergex-child.replace
No stores or magics are added.
If your application already owns an x-child directive — or another toolkit plugin registers on that name — rename the integration surface without touching the unwrap pass:
Alpine.plugin(childPlugin({ directiveKey: "unwrap" })); // → x-unwrapThe exposed constant DEFAULT_CHILD_DIRECTIVE_KEY keeps the rename discoverable from TypeScript.
MIT