DEV Community

Cover image for I built vanilla JS primitives for shadcn/ui data-slot markup
Thom Krupa
Thom Krupa

Posted on

I built vanilla JS primitives for shadcn/ui data-slot markup

shadcn popularized data-slot as a styling hook, but it can be a contract for behavior too.

I built a tiny vanilla JS layer that wires interactions + ARIA onto static HTML for components like Dialog and Tabs, without a framework.

Why I built this

I've been building bejamas/ui, a shadcn-style component library for Astro. It follows the shadcn philosophy: you copy the markup and styling into your project, so you own the code completely.

That works great for styles. But for behavior (focus management, keyboard navigation, ARIA wiring, dismissal logic), copying creates a real problem.

When I fix an accessibility bug in the library, projects using those components don't automatically get the fix. They have to manually re-copy the updated code.

And accessibility bugs are exactly the kind of thing you want to fix once and propagate everywhere.

When “copy and own” breaks down

Many Astro component libraries (including shadcn ports) ship as "copy everything". Styling and client-side JS enhancement are bundled together. It feels convenient: no dependencies, just paste and go.

But here's the trade-off: bugs get into every project that copies them. In React land, shadcn wraps dedicated primitives libraries (Radix or Base UI). When an interaction or accessibility issue is fixed upstream, you update a dependency and you're done.

In Astro shadcn ports, there's no upstream for behavior. The interaction code lives in the pasted component. Every tweak becomes a fork, and there is no easy way to update it.

Focus management, keyboard navigation, nested interactions. This is where the bugs hide, and where you really want fixes to propagate automatically.

This friction is what made me rethink how Astro component libraries should work.

What is the data-slot attribute?

Here's what a typical shadcn wrapper looks like (Tabs root):

<TabsPrimitive.Root data-slot="tabs" ... />
Enter fullscreen mode Exit fullscreen mode

…and another part:

<TabsPrimitive.List data-slot="tabs-list" ... />
Enter fullscreen mode Exit fullscreen mode

In shadcn, data-slot is a stable hook for styling and composition. But it also quietly defines something more interesting:

This element is the Tabs List. This one is the Trigger. This one is the Content.

That's a parts contract. And if the markup already declares the parts, a JS library can wire interactions without needing a framework component model.

So I built a small vanilla JS layer that treats those slots as the public API.

Introducing @data-slot/*

@data-slot/* is a set of tiny, headless behavior primitives. Each primitive exposes a small controller API (createTabs, createDialog) that enhances static HTML.

Install only what you need:

npm add @data-slot/tabs 
npm add @data-slot/dialog
Enter fullscreen mode Exit fullscreen mode

Then in your HTML/Astro:

<div data-slot="tabs" data-default-value="one">
  <div data-slot="tabs-list">
    <button data-slot="tabs-trigger" data-value="one">Tab One</button>
    <button data-slot="tabs-trigger" data-value="two">Tab Two</button>
  </div>
  <div data-slot="tabs-content" data-value="one">Content One</div>
  <div data-slot="tabs-content" data-value="two">Content Two</div>
</div>
Enter fullscreen mode Exit fullscreen mode

Style it however you want (the library toggles data-state and updates ARIA). Here's a minimal example:

[data-slot="tabs-list"] {
    display: flex;
    border-bottom: 1px solid #ccc;
    margin-bottom: 1rem;
}
[data-slot="tabs-trigger"] {
    padding: 0.5rem 1rem;
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    cursor: pointer;
    color: #666;
}
[data-slot="tabs-trigger"][data-state="active"] {
    color: #1a1a1a;
    border-bottom-color: #1a1a1a;
    font-weight: 500;
}
[data-slot="tabs-content"] { padding: 1rem 0; }
[data-slot="tabs-content"][hidden] { display: none; }
Enter fullscreen mode Exit fullscreen mode

And finally the JS to add interaction:

import { createTabs } from 'https://esm.run/@data-slot/tabs'

document.querySelectorAll('[data-slot="tabs"]').forEach(createTabs)
Enter fullscreen mode Exit fullscreen mode

A working example

How this fixes the Astro component problem

If you're building or using an Astro shadcn-style component library, this approach gives you the best of both worlds.

Keep copying what should be copied. Markup structure and styling stay in your project. You own them completely.

Depend on what should be dependencies. Interaction code, focus management, keyboard UX, ARIA logic. When there's a fix, you update a package version instead of hunting through copied code.

Here's what it looks like in practice with bejamas/ui:

Before (simplified):

---
// Tabs.astro (old) — markup + behavior shipped together
// (simplified for the post)
const { defaultValue = "one" } = Astro.props;
---
<div data-slot="tabs" data-default-value={defaultValue}>
  <div data-slot="tabs-list">
    <button data-slot="tabs-trigger" data-value="one">Tab One</button>
    <button data-slot="tabs-trigger" data-value="two">Tab Two</button>
  </div>

  <div data-slot="tabs-content" data-value="one">Content One</div>
  <div data-slot="tabs-content" data-value="two" hidden>Content Two</div>
</div>

<script>
  // Old approach: each copied component ships its own behavior.
  // The full version also handled ARIA wiring, keyboard navigation, focus management, and edge cases.

  function initTabs(root) {
    const triggers = Array.from(root.querySelectorAll('[data-slot="tabs-trigger"]'));
    const contents = Array.from(root.querySelectorAll('[data-slot="tabs-content"]'));

    function setActive(value) {
      // State sync (simplified): data-state + hidden
      triggers.forEach((t) => {
        const active = t.getAttribute("data-value") === value;
        t.setAttribute("data-state", active ? "active" : "inactive");
      });

      contents.forEach((c) => {
        const active = c.getAttribute("data-value") === value;
        c.toggleAttribute("hidden", !active);
        c.setAttribute("data-state", active ? "active" : "inactive");
      });

      // ~30 lines omitted: ARIA wiring + id linking
      // ~50 lines omitted: keyboard navigation + roving tabindex
      // edge cases omitted: disabled triggers, nested focusables, cleanup
    }

    root.addEventListener("click", (e) => {
      const t = e.target.closest('[data-slot="tabs-trigger"]');
      if (!t || !root.contains(t)) return;
      setActive(t.getAttribute("data-value"));
    });

    // Keyboard / focus handling omitted for brevity.

    setActive(root.getAttribute("data-default-value") || "one");
  }

  // Init all instances (script is deduped and runs once)
  document.querySelectorAll('[data-slot="tabs"]').forEach(initTabs);
</script>
Enter fullscreen mode Exit fullscreen mode

After:

---
// Tabs.astro - just markup and tiny JS
---
<div data-slot="tabs" data-default-value={defaultValue}>
  <div data-slot="tabs-list">
    <slot name="triggers" />
  </div>
  <slot name="content" />
</div>

<script>
  import { createTabs } from '@data-slot/tabs'
  document.querySelectorAll('[data-slot="tabs"]').forEach(createTabs);
</script>
Enter fullscreen mode Exit fullscreen mode

The component is cleaner. The styling is still yours to customize. And when I fix a focus trap bug in @data-slot/tabs, every project gets it with a version bump.

What's next?

I'm turning this into a small set of versioned primitives. So far I've shipped:

  • @data-slot/navigation-menu
  • @data-slot/tabs
  • @data-slot/dialog
  • @data-slot/accordion
  • @data-slot/tooltip
  • @data-slot/popover
  • @data-slot/disclosure

Think of @data-slot/* as the primitives layer (like Radix/Base UI), and bejamas/ui as the styled layer (like shadcn).

Code:

Links:

If you're maintaining an Astro component library and hitting similar friction, let's talk!

Top comments (0)