Child
@ailuracode/alpine-child
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.
Install
pnpm add @ailuracode/alpine-child @ailuracode/alpine-core @alpinejs/morph alpinejsQuick start
import 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.
Quick start
<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>Comparison with asChild
| 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.
Modifiers
| 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>Attribute rules
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
Events
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.
Blade components
{{-- 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>.
Limitations
- 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.
API
This package registers a single directive:
x-childx-child.mergex-child.replace
No stores or magics are added.
Avoiding name collisions
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.
License
MIT
