Next Talk: Clean Code is Sexy Again: Making Your Vue Project AI Ready

May 22, 2026 — MadVue, Madrid

Conference
Skip to content

How to Write UI Components That Stay Flexible

Published: at 

Table of Contents#

Open Table of Contents

TLDR#

A Dialog is a great test case for compound components because every product needs three or four versions of one: confirm, edit, share, settings. The naive paths both fail: copy a monolithic dialog into every variant and the shells drift; collapse them into one god component and the props turn into mode, showHeader, showCancel, confirmVariant, headerCentered. The compound pattern Reka UI uses (and shadcn-vue ships) puts state into a provide / inject context and exposes small primitives (<Dialog>, <DialogTrigger>, <DialogContent>, <DialogHeader>, <DialogFooter>, <DialogClose>) that the consumer arranges into the variant they need. The tree expresses the variant; nothing inside the component branches on flags.

This post grew out of Fernando Rojo’s talk Composition Is All You Need. Watch it first if you have 30 minutes. This post is the Vue translation, with Dialog as the running example.

Four Levers, One Dialog STRUCTURE the tree shape IS the variant <Dialog> <DialogTrigger as-child> <MyButton>Share</MyButton> </DialogTrigger> <DialogContent class="max-w-2xl"> <DialogHeader data-state="open"> <DialogTitle>Share link</DialogTitle> </DialogHeader> <DialogClose>Done</DialogClose> </DialogContent> </Dialog> ELEMENT STYLE STATE STRUCTURE (the whole panel) tree shape = variant STYLE via cn() your class wins last STATE stylable contract data-* you can target ELEMENT consumer owns DOM as-child swaps the tag All four levers are decided at the call site.

The whole pattern fits in one snippet: the tree shape is the variant (STRUCTURE), class="..." overrides defaults (STYLE), data-state="..." exposes lifecycle as a stylable contract (STATE), and as-child lets the consumer swap the rendered tag (ELEMENT). The rest of the post is each lever, in detail.

See It Before You Read About It#

Pick a preset. Toggle the children. The dialog on the left and the matching template on the right move together: same primitives, different trees, different variants.

That is the pattern. One root, a few small children. The consumer arranges them, and the arrangement is the variant.

Smell #1: Duplicated Monoliths#

Imagine you write a Dialog the first way most of us do: as one self-contained component. It opens, it closes, it has a header and a footer, you ship it. A week later product asks for an “edit profile” dialog. You copy the file, change the body to a form, ship it. A month later: a “share” dialog with a wider layout. Copy. A confirm-delete with a destructive action. Copy.

MonolithicConfirmDeleteDialog.vue shows one of those copies in practice:

<!-- ANTIPATTERN: shell rebuilt from scratch in every variant. -->
<script setup lang="ts">
import { onUnmounted, ref, useId, watch } from 'vue'

const open = ref(false)
const titleId = useId() ?? 'monolithic-confirm-title'
const descriptionId = useId() ?? 'monolithic-confirm-description'

function setOpen(value: boolean) { open.value = value }
function confirmDelete() { setOpen(false) }

function onKeyDown(e: KeyboardEvent) {
  if (open.value && e.key === 'Escape') {
    e.preventDefault()
    setOpen(false)
  }
}

watch(open, (now, prev) => {
  if (now && !prev) {
    document.body.style.overflow = 'hidden'
    window.addEventListener('keydown', onKeyDown)
  } else if (!now && prev) {
    document.body.style.overflow = ''
    window.removeEventListener('keydown', onKeyDown)
  }
})

onUnmounted(() => {
  document.body.style.overflow = ''
  window.removeEventListener('keydown', onKeyDown)
})
</script>

<template>
  <button @click="setOpen(true)">Delete account</button>

  <Teleport to="body">
    <div v-if="open" class="fixed inset-0 z-40">
      <div aria-hidden="true" class="fixed inset-0 z-40 bg-black/50" @click="setOpen(false)" />
      <div
        :aria-describedby="descriptionId"
        :aria-labelledby="titleId"
        aria-modal="true"
        class="fixed top-1/2 left-1/2 ..."
        role="dialog"
        tabindex="-1"
      >
        <div class="flex flex-col gap-2 text-center sm:text-left">
          <h2 :id="titleId">Are you absolutely sure?</h2>
          <p :id="descriptionId">This will permanently delete your account.</p>
        </div>

        <!-- FOOTER: keep in sync with MonolithicProfileEditDialog.vue -->
        <div class="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
          <button @click="setOpen(false)">Cancel</button>
          <button @click="confirmDelete">Delete account</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

Read that comment: FOOTER: keep in sync with MonolithicProfileEditDialog.vue. That is the smell. Every variant rebuilds the same shell (the overlay, the escape handler, the scroll lock, the title/description ARIA wiring, the footer markup), and the only thing keeping them aligned is grep and discipline.

You pay the cost every time something has to change:

Each copy is a fork. Each fork ages on its own. Six months in, you can’t be sure the dialogs behave the same way, because they don’t.

Smell #2: One God Component With v-ifs#

The obvious next move is to collapse the duplicates into one component. You make the body a slot, expose title and description as props, add showCancel and showConfirm for the footer. It feels clean for one afternoon. Then product asks for a destructive variant; you add confirmVariant. Then a share dialog needs no description; you add showDescription. Then someone wants a centered header; headerCentered. You add a flag for every variant you cram into the component.

BooleanPropDialog.vue shows the version that grew out of covering two real call sites (confirm-delete and edit-profile) with one component:

<!-- ANTIPATTERN: boolean prop sprawl. -->
<script setup lang="ts">
type Mode = 'confirm' | 'edit'
type Variant = 'primary' | 'destructive'

const props = withDefaults(
  defineProps<{
    mode?: Mode
    title: string
    description?: string
    showHeader?: boolean
    showDescription?: boolean
    showFooter?: boolean
    showCancel?: boolean
    showConfirm?: boolean
    showSecondary?: boolean
    showCloseIcon?: boolean
    cancelLabel?: string
    confirmLabel?: string
    secondaryLabel?: string
    confirmVariant?: Variant
    triggerLabel: string
    triggerVariant?: Variant
    headerCentered?: boolean
    disableConfirm?: boolean
    editName?: string
    editBio?: string
  }>(),
  { /* …a wall of defaults… */ }
)
</script>

<template>
  <button v-if="props.triggerVariant === 'destructive'" ...>{{ props.triggerLabel }}</button>
  <button v-else ...>{{ props.triggerLabel }}</button>

  <Teleport to="body">
    <div v-if="open" ...>
      <div aria-hidden="true" ... />
      <div role="dialog" ...>
        <button v-if="props.showCloseIcon" ...>×</button>

        <div v-if="props.showHeader" :class="[
          'flex flex-col gap-2',
          props.headerCentered ? 'text-center' : 'text-left',
        ]">
          <h2>{{ props.title }}</h2>
          <p v-if="props.description && props.showDescription">{{ props.description }}</p>
        </div>

        <div v-if="props.mode === 'edit'" class="mt-4 flex flex-col gap-3">
          <!-- inline form, two-way bound through update:editName / update:editBio -->
        </div>

        <div v-if="props.showFooter" class="mt-6 ...">
          <button v-if="props.showSecondary && props.secondaryLabel" ...>{{ props.secondaryLabel }}</button>
          <button v-if="props.showCancel" ...>{{ props.cancelLabel }}</button>
          <button v-if="props.showConfirm && props.confirmVariant === 'destructive'" ...>{{ props.confirmLabel }}</button>
          <button v-else-if="props.showConfirm" ...>{{ props.confirmLabel }}</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

The call site is the giveaway:

<BooleanPropDialog
  cancel-label="Cancel"
  confirm-label="Delete account"
  confirm-variant="destructive"
  description="This will permanently delete your account..."
  mode="confirm"
  :show-cancel="true"
  :show-confirm="true"
  title="Are you absolutely sure?"
  trigger-label="Delete account"
  trigger-variant="destructive"
  @confirm="confirmDelete"
/>

Twelve flags to describe one variant. The edit version needs mode="edit", then plumbs the form’s two-way binding through update:editName and update:editBio because the inputs live inside the dialog the consumer can’t reach into.

Fernando Rojo named this smell in his talk:

“If you have a boolean prop that determines which component tree is getting rendered from the parent, you can kind of imagine me looking over your shoulder and shaking my head.”

— Fernando Rojo, Composition Is All You Need

The diagnostic: when a flag changes what renders rather than how, lift it into a child component. confirmVariant="destructive" is a “how” prop and is fine. mode="edit" is a “what” prop. It switches the component tree, so lift it into the tree the consumer controls.

The Solution: Compose Trees, Don’t Configure Them#

The compound component pattern flips the relationship. You ship N small components that share state through a provider. The consumer assembles only the parts they need:

<Dialog>
  <DialogTrigger>Delete account</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Are you absolutely sure?</DialogTitle>
      <DialogDescription>This cannot be undone.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose>Cancel</DialogClose>
      <button class="bg-red-600 ..." @click="confirmDelete">Delete</button>
    </DialogFooter>
  </DialogContent>
</Dialog>

A share dialog with no description? Drop <DialogDescription>. An edit dialog with a form? Put the form inside <DialogContent>. The inputs are in the consumer’s template, so they bind to the consumer’s state with v-model. A settings menu where the trigger is a list-row? Pass as-child to <DialogTrigger>. No flags inside the component, no update:editName plumbing across the boundary.

“Where are the booleans and the special props telling us what to render? They’re nowhere to be found. We don’t have a monolith — we have shared internals that get reimplemented for each use case.”

— Fernando Rojo

This is how Reka UI is built and how shadcn-vue ships every primitive on top of it.

Reading shadcn-vue: The Dialog Primitive#

Installing the Dialog from shadcn-vue gives you a barrel of small files, not one <Dialog> component:

// ui/dialog/index.ts
export { default as Dialog } from "./Dialog.vue";
export { default as DialogClose } from "./DialogClose.vue";
export { default as DialogContent } from "./DialogContent.vue";
export { default as DialogDescription } from "./DialogDescription.vue";
export { default as DialogFooter } from "./DialogFooter.vue";
export { default as DialogHeader } from "./DialogHeader.vue";
export { default as DialogOverlay } from "./DialogOverlay.vue";
export { default as DialogTitle } from "./DialogTitle.vue";
export { default as DialogTrigger } from "./DialogTrigger.vue";

Nine components, each doing one thing. The root that owns the state, Dialog.vue, looks like this:

<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui";
import { DialogRoot, useForwardPropsEmits } from "reka-ui";

const props = defineProps<DialogRootProps>();
const emits = defineEmits<DialogRootEmits>();

const forwarded = useForwardPropsEmits(props, emits);
</script>

<template>
  <DialogRoot data-slot="dialog" v-bind="forwarded">
    <slot />
  </DialogRoot>
</template>

The root renders no markup of its own. It forwards into Reka UI’s DialogRoot, which establishes the context. The open/closed state lives there and children read it via inject.

DialogHeader is a styled wrapper with no logic:

<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>

<template>
  <div
    data-slot="dialog-header"
    :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
  >
    <slot />
  </div>
</template>

The trigger is a few lines too. It forwards to Reka’s DialogTrigger, which calls setOpen(true) and wires up the ARIA attributes. No DOM owned by shadcn-vue, only styling and the data-slot for theming.

Compare the call site (the compound version we saw above) to the boolean-prop monolith: same UX, twelve flags fewer, and when a designer asks for a checkbox in the footer or a dialog with no description, you rearrange the tree to fit.

A Quick Detour: How provide and inject Work#

Compound components rely on Vue’s provide and inject API. The model matches React Context: a parent publishes a value under a key, and any descendant in its subtree reads that value without the components in between knowing about it.

The minimum example is two components. Click the button — the child mutates the shared ref, the parent re-renders. The same open lives in both:

<script setup>
import { provide, ref } from "vue";
import Child from "./Child.vue";

const open = ref(false);
provide("dialog-open", open);
</script>

<template>
  <p>Parent sees: open = <strong>{{ open }}</strong></p>
  <Child />
</template>
Live preview

The Vue docs frame this as the answer to prop drilling: passing the same prop through three intermediate components that do not care about it, only to reach the one that does. With provide / inject, the descendant reads from the closest matching provider in the tree, however many components deep it is.

Toggle the root state below. On the left, every component on the path carries open as a prop. On the right, only the provider and the consumers know about it. <Header>, <Body>, and <Footer> are unchanged.

Three properties of the API matter for <Dialog>:

Vue also lets you pass readonly() around a provided ref so descendants can read but not mutate. Dialog skips this because <DialogClose> should mutate, but readonly fits when only the provider holds the write path.

Building Your Own: A Dialog Component#

Let’s build the compound Dialog from scratch. This is the part you will reuse on your own components.

Step 1: The provider owns state#

Start with a composable that defines the shared interface. This is your contract:

// composables/useDialog.ts
import { inject, provide, type InjectionKey, type Ref } from "vue";

export interface DialogContext {
  open: Ref<boolean>;
  setOpen: (value: boolean) => void;
  triggerRef: Ref<HTMLElement | null>;
  contentId: string;
  titleId: string;
  descriptionId: string;
}

const DialogKey: InjectionKey<DialogContext> = Symbol("dialog");

export function provideDialog(ctx: DialogContext) {
  provide(DialogKey, ctx);
}

export function useDialog(): DialogContext {
  const ctx = inject(DialogKey);
  if (!ctx) throw new Error("useDialog must be used inside <Dialog>");
  return ctx;
}

The InjectionKey is typed, so children get full type safety on the context. useDialog throws when used outside the provider, which prevents silent bugs in consumer code.

If you use VueUse, there is a helper for this#

The compound pattern is two halves: the root provides, the leaves inject. VueUse’s createInjectionState is shaped exactly around that — it takes a setup function and returns a [useProvide, useInject] tuple. The same useDialog.ts becomes:

// composables/useDialog.ts
import { createInjectionState } from "@vueuse/core";
import { ref, useId } from "vue";

const [useProvideDialog, useDialogRaw] = createInjectionState(() => {
  const open = ref(false);
  const triggerRef = ref<HTMLElement | null>(null);
  const contentId = useId() ?? "dialog-content";
  const titleId = useId() ?? "dialog-title";
  const descriptionId = useId() ?? "dialog-description";

  function setOpen(value: boolean) {
    open.value = value;
  }

  return { open, setOpen, triggerRef, contentId, titleId, descriptionId };
});

export { useProvideDialog };

export function useDialog() {
  const ctx = useDialogRaw();
  if (!ctx) throw new Error("useDialog must be used inside <Dialog>");
  return ctx;
}

The state and the ARIA ids now live inside the setup function, so the root’s <script setup> collapses to a single useProvideDialog() call — no manually defined InjectionKey, no separate provideDialog(ctx) wiring. The leaves still get a typed useDialog() that throws when used outside the provider. (createInjectionState also accepts a defaultValue option that returns a fallback instead of undefined; for compound components you usually want the throw, since a missing provider is a real bug, not a graceful-degradation case.)

Two payoffs beyond saved boilerplate:

The rest of this section uses the manual version because it shows the raw provide / inject shape with no library in the way. Reach for createInjectionState once you have a few compound components in the codebase, or once you want to test the state machine in isolation.

Step 2: The root injects, doesn’t render#

The Dialog.vue root creates the state, generates ARIA ids, and provides the bundle. It renders no markup of its own. It is a logical container:

<!-- Dialog.vue -->
<script setup lang="ts">
import { ref, useId } from "vue";
import { provideDialog } from "@/composables/useDialog";

const open = defineModel<boolean>("open", { default: false });
const triggerRef = ref<HTMLElement | null>(null);

const contentId = useId() ?? "dialog-content";
const titleId = useId() ?? "dialog-title";
const descriptionId = useId() ?? "dialog-description";

function setOpen(value: boolean) {
  open.value = value;
}

provideDialog({ open, setOpen, triggerRef, contentId, titleId, descriptionId });
</script>

<template>
  <slot />
</template>

defineModel exposes v-model:open so the consumer can read or control the open state from outside. useId gives stable IDs for the title/description so <DialogTitle> and <DialogDescription> can publish them and <DialogContent> can reference them via aria-labelledby / aria-describedby.

Step 3: Each child is small and focused#

Children only know about the slice of context they need.

<!-- DialogTrigger.vue -->
<script setup lang="ts">
import { useDialog } from "@/composables/useDialog";

const { open, setOpen, triggerRef, contentId } = useDialog();

function handleClick(e: MouseEvent) {
  triggerRef.value = e.currentTarget as HTMLElement;
  setOpen(true);
}
</script>

<template>
  <button
    type="button"
    :aria-controls="contentId"
    :aria-expanded="open"
    :data-state="open ? 'open' : 'closed'"
    @click="handleClick"
  >
    <slot />
  </button>
</template>
<!-- DialogClose.vue -->
<script setup lang="ts">
import { useDialog } from "@/composables/useDialog";

const { setOpen } = useDialog();
</script>

<template>
  <button type="button" @click="setOpen(false)">
    <slot>Cancel</slot>
  </button>
</template>
<!-- DialogTitle.vue — publishes the id to satisfy aria-labelledby -->
<script setup lang="ts">
import { useDialog } from "@/composables/useDialog";

const { titleId } = useDialog();
</script>

<template>
  <h2 :id="titleId"><slot /></h2>
</template>

<DialogContent> is the heaviest leaf because it owns the open/close transition, focus management, scroll lock, and the escape handler. But all of those used to be duplicated across every monolithic dialog. Lifting them into one place is the entire point. When accessibility audits ask for a focus trap, you change one file:

<!-- DialogContent.vue (focus + escape, abridged) -->
<script setup lang="ts">
import { nextTick, onUnmounted, ref, watch } from "vue";
import { useDialog } from "@/composables/useDialog";

const { open, setOpen, triggerRef, contentId, titleId, descriptionId } = useDialog();
const contentRef = ref<HTMLElement | null>(null);

function onKeyDown(e: KeyboardEvent) {
  if (open.value && e.key === "Escape") {
    e.preventDefault();
    setOpen(false);
  }
}

watch(open, async (now, prev) => {
  if (now && !prev) {
    document.body.style.overflow = "hidden";
    window.addEventListener("keydown", onKeyDown);
    await nextTick();
    contentRef.value?.querySelector<HTMLElement>("button, input, [tabindex]")?.focus();
  } else if (!now && prev) {
    document.body.style.overflow = "";
    window.removeEventListener("keydown", onKeyDown);
    triggerRef.value?.focus();
  }
});

onUnmounted(() => {
  document.body.style.overflow = "";
  window.removeEventListener("keydown", onKeyDown);
});
</script>

<template>
  <Teleport to="body">
    <Transition>
      <div v-if="open" class="fixed inset-0 z-40">
        <DialogOverlay />
        <div
          :id="contentId"
          ref="contentRef"
          :aria-describedby="descriptionId"
          :aria-labelledby="titleId"
          aria-modal="true"
          role="dialog"
          tabindex="-1"
        >
          <slot />
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

The work that lived inside every monolithic dialog now lives in one component. Consumers compose instead of copy.

Step 4: Compose distinct variants#

Now the same primitives produce every variant. Confirm delete:

<Dialog>
  <DialogTrigger class="bg-red-600 ...">Delete account</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Are you absolutely sure?</DialogTitle>
      <DialogDescription>This cannot be undone.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose>Cancel</DialogClose>
      <button class="bg-red-600 ..." @click="confirmDelete">Delete account</button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Edit profile, with the form sitting in the consumer’s template so v-model binds to the consumer’s state without crossing the dialog boundary:

<Dialog>
  <DialogTrigger>Edit profile</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
      <DialogDescription>Make changes here. Click save when you're done.</DialogDescription>
    </DialogHeader>
    <form @submit.prevent="save">
      <input v-model="profile.name" />
      <textarea v-model="profile.bio" />
      <DialogFooter>
        <DialogClose>Cancel</DialogClose>
        <button type="submit">Save changes</button>
      </DialogFooter>
    </form>
  </DialogContent>
</Dialog>

Notice what’s gone: no update:editName plumbing, no mode="edit" flag, no shell rebuilt. The form is the consumer’s template; the dialog scaffolding is the primitive.

Share dialog, wider content, icon close instead of a button. Same primitives, different tree:

<Dialog>
  <DialogTrigger>Share project</DialogTrigger>
  <DialogContent class="max-w-2xl">
    <DialogHeader>
      <DialogTitle>Share "Project Phoenix"</DialogTitle>
    </DialogHeader>
    <DialogClose as-child>
      <button class="absolute top-4 right-4" aria-label="Close"></button>
    </DialogClose>
    <ShareLinkRow />
    <CollaboratorList />
    <DialogFooter>
      <DialogClose class="bg-zinc-900 text-white">Done</DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>

The whole thing, running#

Five small files. Click through the tabs to see the implementation, then click the trigger. The provider lives in Dialog.vue; trigger and close call setOpen on the same context; content reads open to render.

<script setup>
import Dialog from "./Dialog.vue";
import DialogTrigger from "./DialogTrigger.vue";
import DialogContent from "./DialogContent.vue";
import DialogClose from "./DialogClose.vue";

function confirmDelete() {
  alert("Account deleted!");
}
</script>

<template>
  <Dialog>
    <DialogTrigger>Delete account</DialogTrigger>
    <DialogContent>
      <h2>Are you absolutely sure?</h2>
      <p>This will permanently delete your account.</p>
      <div class="footer">
        <DialogClose>Cancel</DialogClose>
        <button class="danger" @click="confirmDelete">
          Delete account
        </button>
      </div>
    </DialogContent>
  </Dialog>
</template>

<style scoped>
.footer {
  display: flex;
  gap: 0.5rem;
  justify-content: flex-end;
  margin-top: 1.25rem;
}
.danger {
  background: #dc2626;
  color: white;
  border-color: #dc2626;
}
h2 {
  margin: 0 0 0.5rem;
  font-size: 1.05rem;
  font-weight: 600;
}
p {
  margin: 0;
  opacity: 0.8;
  font-size: 0.9rem;
}
</style>
Live preview

No flag inside any file decides what gets rendered. The variant is the tree in App.vue. Swap <DialogContent>’s children for a form and you have edit-profile; swap them for a share sheet and you have share. Same primitives, same provider, different tree.

State Lives in the Provider, Not the Layout#

“If there’s one thing to take away from this talk, it would be this. I’ve solved so many problems in my React code bases by simply lifting state higher up in the tree.”

— Fernando Rojo

The share example tucked an icon <DialogClose> outside the footer, absolutely positioned in the top-right corner. It still closed the same dialog because the open state lives in the provider, not in any specific child. Slots alone cannot reach across siblings like that; a provider can.

Open the dialog below, then move the cancel button between the footer and a toolbar that sits next to the dialog. Both consumers call setOpen(false) on the same provider. The visual layout and the state graph are decoupled.

In a slot-based design without a provider you would have to do one of these by hand:

With provide / inject, none of that exists. A child calls useDialog() and the closest provider answers, no matter how deep in the tree or how far apart the children sit in the layout. “Lift state to the provider” means lifting it out of the layout. The same state can then be read from any DOM position the consumer wants.

Styling Without Locking Consumers In: cn()#

That covers structure. The consumer composes the tree, the provider keeps state in sync no matter how the children are laid out. Styling is the second axis. The primitive ships sensible defaults: flex flex-col gap-2 on a header, max-w-md on the content. Every real call site eventually wants to bend one of them: a wider share dialog, a tighter footer, a destructive button that breaks the default palette. The consumer needs a way in without forking the file.

cn(...) is how shadcn-vue solves that. It is a small utility every shadcn project writes itself, by convention living in src/lib/utils.ts (not an npm package).

The implementation is four lines:

// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

clsx joins truthy class values into a string. tailwind-merge deduplicates conflicting Tailwind utilities so the last one wins, so p-2 followed by p-4 collapses to p-4. Together, whatever the consumer passes overrides whatever the primitive sets, with no specificity wars.

In every shadcn-vue primitive it shows up as the same one-liner. From DialogHeader:

<div :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
  <slot />
</div>

The primitive holds the default styling, props.class carries the override, and the merge runs defaults first so the override wins on conflicts.

Watch the merge happen#

Toggle classes on either side. The left panel shows what naive concatenation produces: conflicting utilities sit side-by-side, flagged with ⚠. The right panel runs the same input through the real cn() (using clsx + tailwind-merge from npm) and shows which classes survive and which get dropped.

Conflict chips on the left are the ones whose winner depends on stylesheet order; fragile. Strikethrough chips on the right are the ones tailwind-merge dropped. Hover any dropped chip to see who beat it. Non-conflicting classes (like text-white when no other text-color is set) flow through untouched on both sides, since cn() deduplicates only when there is a real conflict.

So the share dialog’s <DialogContent class="max-w-2xl"> works: the primitive’s max-w-md default loses to the consumer’s max-w-2xl, no specificity war, no fork.

State as Contract: The data-* Pattern#

cn() covers static overrides. But every dialog also has state: open, closed, animating in, animating out, disabled. The consumer wants to style against those too. A fade on open, a slide on close, a muted border when disabled. The primitive cannot ship every animation, and it shouldn’t have to.

Headless libraries solve this by writing state to the DOM as data attributes, then letting the consumer style against them. You already saw both halves of the pattern in the snippets above.

data-state: the current state of the component#

The library sets it automatically. Look at the trigger we built earlier:

:data-state="open ? 'open' : 'closed'"

The consumer styles against the attribute:

<DialogContent
  class="data-[state=open]:animate-in data-[state=closed]:fade-out"
/>

Tailwind v4 ships data-[...] as a first-class variant, so no plugin is needed. The same vocabulary shows up across the ecosystem: data-state="open" | "closed" on overlays, "on" | "off" on toggles, "checked" | "unchecked" | "indeterminate" on checkboxes, "active" | "inactive" on tabs. Reka UI and Radix share the contract, so a class targeting data-[state=open]: works against either.

data-slot: stable identity for each part#

shadcn-vue v4 stamps a data-slot on every primitive. The dialog root carries data-slot="dialog", the header data-slot="dialog-header", and so on. You saw it in the shadcn source above:

<DialogRoot data-slot="dialog" v-bind="forwarded">
<div data-slot="dialog-header" :class="cn(...)">

Why a separate attribute when class exists? Because class belongs to the consumer (cn() already settled that), and Tailwind utility names are not semantic. data-slot is. A parent layout can target a dialog part without knowing or caring which utilities live inside it today:

<form class="[&_[data-slot=dialog-footer]]:gap-3">
  <!-- Any dialog mounted inside picks up gap-3 on its footer, no matter how deep. -->
</form>

Other attributes you’ll see#

Why attributes, not classes#

  1. The consumer owns class. If the library wrote state classes like is-open, it would have to merge with the consumer’s input or fight specificity. Attributes sidestep both.
  2. Stable surface. Refactor the primitive’s utility classes tomorrow; data-state="open" survives because it is the public contract.
  3. Inspectable. Open DevTools, see the state in the DOM. No closure spelunking.
  4. Composable with Tailwind variants. data-[state=open]:, group-data-[state=open]:, and has-[[data-slot=dialog-footer]]: are first-class variants in v4.

Element Handoff: as-child#

One more escape hatch shows up in shadcn-vue source: as-child (inherited from Reka UI, which inherits it from Radix). Pass it to a primitive and the primitive clones the consumer’s child and forwards its behavior into that element rather than rendering its own DOM.

<!-- Default: DialogTrigger renders its own <button> -->
<DialogTrigger>Open</DialogTrigger>

<!-- as-child: DialogTrigger borrows your <RouterLink>'s DOM,
     keeps the open-on-click behavior, but the rendered tag is <a> -->
<DialogTrigger as-child>
  <RouterLink to="/profile">Open profile</RouterLink>
</DialogTrigger>

The contract: the consumer’s element receives all the props and event handlers the primitive would have applied to its own DOM. The primitive owns behavior; the consumer owns presentation. (Reka UI implements this via its <Primitive> component and slot forwarding, so you do not need to write the cloning machinery yourself.)

This is the fourth axis of consumer override the pattern gives you. Compound children let the consumer compose structure, cn() lets them override style, data-* exposes state and identity as a stylable contract, and as-child lets them swap the rendered element. All four are decided at the call site.

The Convenience Layer#

Once a compound API exists, someone will ask for a flat <ConfirmDialog title="..." description="..." /> shortcut. Don’t add it as flags on <Dialog>. Build it on top of the primitives:

<!-- ConfirmDialog.vue: a higher-level wrapper, NOT a flag added to Dialog -->
<script setup lang="ts">
import {
  Dialog, DialogClose, DialogContent, DialogDescription,
  DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/dialog";

withDefaults(
  defineProps<{
    title: string;
    description?: string;
    confirmLabel?: string;
    cancelLabel?: string;
    destructive?: boolean;
  }>(),
  { confirmLabel: "Confirm", cancelLabel: "Cancel", destructive: false },
);

const emit = defineEmits<{ confirm: [] }>();
const open = defineModel<boolean>("open", { default: false });

function handleConfirm() {
  emit("confirm");
  open.value = false;
}
</script>

<template>
  <Dialog v-model:open="open">
    <DialogTrigger v-if="$slots.trigger" as-child>
      <slot name="trigger" />
    </DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>{{ title }}</DialogTitle>
        <DialogDescription v-if="description">{{ description }}</DialogDescription>
      </DialogHeader>
      <DialogFooter>
        <DialogClose>{{ cancelLabel }}</DialogClose>
        <button :class="destructive ? 'bg-red-600 ...' : 'bg-zinc-900 ...'" @click="handleConfirm">
          {{ confirmLabel }}
        </button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
</template>

Now a sign-out confirm is one tag:

<ConfirmDialog
  title="Sign out of your account?"
  description="You'll need to sign in again next time you visit."
  confirm-label="Sign out"
  cancel-label="Stay signed in"
  @confirm="onSignOut"
>
  <template #trigger>
    <button>Sign out</button>
  </template>
</ConfirmDialog>

The convenience layer is a consumer of the primitives, not an extension of them. When a designer asks for a non-standard variant (checkbox in the footer, custom layout), the consumer drops back down to the primitives without anyone touching ConfirmDialog. This is what stops the compound API from collapsing back into BooleanPropDialog over time.

How to Apply This in Your Codebase#

I use this workflow to migrate an existing prop-heavy component:

  1. Find the smell. List every boolean prop. For each one, ask: does this prop change what renders or how it renders? “What” props are composition opportunities. “How” props (variant, size, color) are fine; leave them.
  2. Sketch the tree per variant. Take three real call sites and write down the JSX/template you wish you could write. The compound API drops out of that exercise.
  3. Extract the provider. Move state and handlers into a composable with a typed InjectionKey. The consumer-facing interface is the type signature of that context object.
  4. Split the children. Each child component reads only the slice it needs from useX(). Keep them dumb. No business logic in the leaves.
  5. Delete the old props one variant at a time. You can ship the new API alongside the old monolith and migrate call sites incrementally.

A few rules that have saved me pain:

When Not to Use It#

The compound pattern has real costs. Skip it when:

A <UserAvatar :size="md" :src="..." /> does not need to be compound. A modal with a form, a footer, and three layouts does.

Connections#

This pattern lives across the ecosystem under different names. The unifying idea, push the variant decision out of the component and into the call site, shows up in three traditions worth reading.

On the React side, Kent C. Dodds canonized the modern compound-components-with-context approach in Compound Components with React Hooks. His framing of the pattern as “an implicit contract between parent and children, eliminating verbose prop passing” is the cleanest single statement of why this beats props. patterns.dev’s Compound Pattern is the canonical reference for the React variant.

Radix UI (which Reka UI mirrors for Vue) builds compound on top of two primitives: a context provider and the asChild composition prop, which lets consumers swap the rendered element while keeping the behavior. The Radix philosophy doc is the architecture-in-one-page version: “primitives ship with zero presentational styles” and “components are designed with an open API that provides consumers with direct access to the underlying DOM node.” The whole shadcn-vue ecosystem flows from those two lines.

On the Vue side, Adam Wathan’s Advanced Vue Component Design (2018) was the first widely-shared treatment of compound, slots, and providers as a single toolkit. Michael Thiessen catalogs the per-pattern variants in 12 Design Patterns in Vue, The 6 Levels of Reusability, and the small applied exercise Building a (Totally) Unnecessary If/Else Component. The last one builds an <If><Else /> pair from scratch using provide/inject, a tighter playground than Dialog if you want to see the wiring on a smaller surface.

All three traditions share the same shift from configuration to composition: hand the component its pieces instead of describing them with flags.

References#

Press Esc or click outside to close

Stay Updated!

Subscribe to my newsletter for more TypeScript, Vue, and web dev insights directly in your inbox.

  • Background information about the articles
  • Weekly Summary of all the interesting blog posts that I read
  • Small tips and trick
Subscribe Now