If you've ever admired shadcn/ui's clean, modern aesthetic but felt stuck because your next project uses Vue, Svelte, or plain HTML, you're not alone. The assumption that shadcn/ui is purely a React library often stops developers from leveraging its design system elsewhere. In reality, shadcn/ui is just a collection of copy-pasteable components built on top of Tailwind CSS. The core visual identity comes from utility classes and a well-crafted design token system—not from React itself. This means you can extract the CSS, adapt the markup, and achieve identical visual results in any framework.
The real pain point is maintaining design consistency across a React frontend and a non-React landing page, dashboard, or marketing site. You want the same buttons, cards, and modals without rebuilding the look from scratch. This tutorial shows you exactly how to do that. We'll start by isolating the styles and markup from shadcn/ui, then walk through concrete adaptations for Vue 3, Svelte, and vanilla HTML with Tailwind. By the end, you'll have a reusable design system without the React lock-in.
What Makes shadcn/ui Portable Outside React
At its core, shadcn/ui is a collection of beautifully styled components built with two key layers: Tailwind CSS for every pixel of visual design and Radix UI for behavior like toggling, focus management, and keyboard navigation. The genius—and the reason you can use shadcn/ui without React—is that the visual layer is entirely framework-agnostic, while the behavior layer is the only part tied to React.
Think of it like a recipe: Tailwind provides the list of ingredients (utility classes like bg-blue-500, rounded-lg, shadow) and the markup gives you instructions (which elements to wrap, which classes to apply). Radix UI is like the stove—it controls the heat, timing, and motion. You can perfectly follow the ingredient list and instructions, but swap the stove for a Vue, Svelte, or vanilla JavaScript approach.
Let’s look at a concrete example: a shadcn/ui Button. The static part is pure HTML with Tailwind classes—a <button> element with classes like inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2. This markup is identical whether you use React, Vue, or plain HTML. The interactive behavior—like handling a click event or exposing a disabled state—is where React and Radix come in. In a React shadcn Button, that logic lives inside the component using Radix’s Button primitive, which adds accessibility and event management. When you extract the component for another framework, you keep the entire Tailwind class string and the HTML structure, but you replace the Radix-driven logic with your framework’s own event system (e.g., @click in Vue or on:click in Svelte).
In short, shadcn/ui’s portability rests on the fact that Tailwind utility classes capture 100% of the look, while the behavior is abstracted by Radix—and that abstraction is what you’re free to replace. This makes shadcn/ui a design system in essence, not just a React library.
Extracting shadcn/ui Styles and Utilities
Now that you understand shadcn/ui's architecture, it's time to isolate the portable parts. The beauty of shadcn/ui is that every component's source lives in your project as plain files — no hidden library magic. Follow these steps to extract styles for use outside React.
Step 1: Locate the Component Files
If you used the shadcn/ui CLI to add components (e.g., npx shadcn-ui@latest add card), your project will have a components/ui/ directory. Each component is a .tsx file. For a manual setup, copy the source from the official shadcn/ui website's component pages. You will typically need:
-
components/ui/card.tsx(or whichever component you want) -
lib/utils.ts(contains thecn()helper for merging Tailwind class strings) -
app/globals.css(contains Tailwind directives and any custom CSS variables)
Step 2: Strip React-Specific Logic
Open a .tsx file like card.tsx. You'll see a mix of:
-
Tailwind utility classes (e.g.,
rounded-xl,bg-card,text-card-foreground) -
Radix UI imports (e.g.,
@radix-ui/react-accordion) — discard these -
React components and hooks (e.g.,
React.forwardRef,onClick) — remove the React wrapper
For example, the shadcn Card component's structure is essentially a <div> with nested children, each carrying Tailwind classes. The React code just renders a div with a dynamic class string. You can extract the class string directly.
Step 3: Create a Standalone CSS File
Instead of copy-pasting long class strings into every HTML file, create a dedicated CSS file. Take the Tailwind classes from the Card component and translate them into a reusable class name.
Example: shadcn Card extracted to shadcn-card.css
.shadcn-card {
border-radius: 0.75rem; /* rounded-xl */
border: 1px solid hsl(var(--border));
background-color: hsl(var(--card));
color: hsl(var(--card-foreground));
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.shadcn-card-header {
display: flex;
flex-direction: column;
gap: 0.375rem; /* space-y-1.5 */
padding: 1.5rem; /* p-6 */
}
.shadcn-card-title {
font-size: 1.5rem; /* text-2xl */
font-weight: 600; /* font-semibold */
line-height: 1; /* leading-none */
letter-spacing: -0.025em; /* tracking-tight */
}
.shadcn-card-description {
font-size: 0.875rem; /* text-sm */
color: hsl(var(--muted-foreground));
}
.shadcn-card-content {
padding: 1.5rem; /* p-6 */
padding-top: 0; /* pt-0 */
}
.shadcn-card-footer {
display: flex;
align-items: center;
padding: 1.5rem; /* p-6 */
padding-top: 0; /* pt-0 */
}
If you are using Tailwind in your target project, you can instead keep the utility classes inline. The above CSS approach works for any project, even without Tailwind, by using the computed CSS variable values from globals.css.
Checklist of Files to Grab
- [ ]
components/ui/[component].tsx— extract Tailwind classes - [ ]
lib/utils.ts— copy thecn()function for class merging - [ ]
app/globals.css— copy CSS variables (e.g.,--card,--border,--muted-foreground) and Tailwind directives
Once you have these, you can drop the React-specific files and focus on adapting the markup. The CSS variables from globals.css define the design token values — keep those intact to preserve shadcn/ui's visual identity.
Adapting shadcn/ui Components for Vue 3
Vue 3's composition API and single-file components make it straightforward to preserve shadcn/ui's visual identity while replacing React-specific logic with Vue idioms. Start by copying the Tailwind classes from shadcn/ui's source. For a Button component, you can replicate the exact styling by using the same utility classes in a .vue file.
Simple Static Button
<template>
<button
:class="['inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background', variantClass]"
>
<slot />
</button>
</template>
<script setup>
const props = defineProps({
variant: { type: String, default: 'default' }
});
const variantClass = computed(() => {
const variants = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground'
};
return variants[props.variant] || variants.default;
});
</script>
Here, defineProps replaces React's PropTypes, and computed handles dynamic class binding. Emits for click events are handled natively via @click on the template—no onClick prop needed.
Interactive Card with State
For a Card component with expand/collapse behavior, adapt shadcn/ui's Card structure using Vue's scoped styles for isolation:
<template>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div class="flex flex-row items-center justify-between p-6" @click="isOpen = !isOpen">
<h3 class="text-lg font-semibold">{{ title }}</h3>
<span class="text-sm text-muted-foreground">{{ isOpen ? '▲' : '▼' }}</span>
</div>
<div v-show="isOpen" class="p-6 pt-0">
<slot />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({ title: String });
const isOpen = ref(true);
</script>
<style scoped>
/* Optional: add custom scoped styles if needed, but Tailwind covers most */
</style>
Vue's v-show toggles content while preserving the layout, and scoped styles ensure the Tailwind classes don't leak. This pattern works for Dialog, Alert, or any interactive component—replace React's useState with ref() and event handlers with Vue directives. For more complex state, use Pinia or composables. By treating shadcn/ui as a design token source, you keep the look without the React lock-in.
Porting shadcn/ui Components to Svelte
While Vue offers a familiar reactivity system, Svelte’s compiler-driven approach simplifies porting shadcn/ui even further. Because Svelte handles reactive state at compile time, you can directly replace Radix UI’s imperative logic with Svelte’s declarative blocks and built-in animations.
Porting the shadcn/ui Alert Component
The Alert component in shadcn/ui uses Tailwind classes for its visual style and Radix for dismissible behavior. To port it to Svelte, copy the HTML structure and utility classes, then add a reactive variable for visibility.
<script>
let visible = true;
</script>
{#if visible}
<div class="relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<h5 class="mb-1 font-medium leading-none tracking-tight">Alert Title</h5>
<p class="text-sm [&_p]:leading-relaxed">This is a shadcn/ui alert ported to Svelte.</p>
<button on:click={() => visible = false} class="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100">
✕
</button>
</div>
{/if}
Notice how the Radix dismiss functionality is replaced by a simple Svelte reactive variable (visible). The Tailwind classes remain untouched, preserving shadcn/ui’s exact visual appearance.
Using {#each} for List-Based Components like shadcn/ui Tabs
shadcn/ui’s Tabs component uses Radix’s TabsList and TabsContent to switch between panels. In Svelte, you can replicate this with an array of items and an {#each} block combined with a reactive variable.
<script>
let tabs = [
{ id: 'tab1', label: 'Overview', content: 'Overview content here.' },
{ id: 'tab2', label: 'Settings', content: 'Settings content here.' },
];
let activeTab = 'tab1';
</script>
<div class="w-full">
<div class="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
{#each tabs as tab}
<button
on:click={() => activeTab = tab.id}
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all
{activeTab === tab.id ? 'bg-background text-foreground shadow-sm' : ''}"
>
{tab.label}
</button>
{/each}
</div>
<div class="mt-2">
{#each tabs as tab}
{#if activeTab === tab.id}
<p class="text-sm text-muted-foreground">{tab.content}</p>
{/if}
{/each}
</div>
</div>
Here, {#each} iterates over the tabs array, and a conditional {#if} shows the active content. This pattern cleanly replaces Radix’s TabsContent and requires no external headless library.
Replacing Radix Primitives with Svelte’s Style Slots
Radix provides slots for composition (e.g., asChild). Svelte’s <slot> element offers a similar pattern without a library. For a shadcn/ui Button, you can create a reusable component that accepts child elements via the default slot.
<!-- Button.svelte -->
<button class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90">
<slot />
</button>
Using it elsewhere: <Button>Click me</Button>. This simple slot mechanism replaces Radix’s Slot primitive, keeping your markup lean and framework-native.
Svelte’s minimal boilerplate makes porting shadcn/ui components straightforward. You keep the cherished Tailwind design, ditch the React dependency, and achieve the same interactive results—all while writing less code than the React original. This approach perfectly aligns with the goal of using shadcn ui without react, giving you a standalone design system that works with Svelte’s reactive model.
Using shadcn/ui in Vanilla HTML with Tailwind
The fastest way to use shadcn/ui components beyond React is to drop them directly into a plain HTML file. Since the visual layer is pure Tailwind CSS, you can copy the markup and utility classes exactly as they appear in the shadcn/ui documentation, add Tailwind via CDN, and get a fully styled interface in seconds.
Here’s a complete HTML page with a shadcn-style button and a card:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<title>shadcn/ui Vanilla Demo</title>
</head>
<body class="flex items-center justify-center min-h-screen bg-gray-100">
<div class="max-w-sm rounded-xl border bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold">Card Title</h2>
<p class="mt-2 text-sm text-gray-500">This card uses the same utility classes as shadcn/ui’s Card component.</p>
<button class="mt-4 inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90">
Click Me
</button>
</div>
</body>
</html>
To add interactivity, write a small JavaScript snippet. For example, toggling a mobile menu:
<button id="menu-btn" class="...">Menu</button>
<div id="menu" class="hidden ...">
<a href="#">Link</a>
</div>
<script>
const btn = document.getElementById('menu-btn');
const menu = document.getElementById('menu');
btn.addEventListener('click', () => {
menu.classList.toggle('hidden');
});
</script>
The Tailwind CDN is perfect for quick prototypes, landing pages, or internal tools. However, for production sites, using a build step (e.g., with Vite + Tailwind CLI) is recommended to purge unused styles and optimize bundle size. This approach keeps the familiar shadcn/ui look while completely eliminating React from your stack.
Bringing It Together for Your Next Project
You now have the tools to break shadcn/ui free from its React shell. Whether you choose Vue, Svelte, or plain HTML, the visual polish of shadcn/ui is yours to reuse by extracting the Tailwind CSS and reimplementing interactivity with your framework of choice.
Here is a quick checklist to guide your next project:
- Audit your existing UI – Identify components you want to port and note any framework-specific behaviors.
- Decide on your target framework – Vue for reactivity, Svelte for minimal overhead, or vanilla HTML for static sites.
- Extract shadcn styles – Copy the component source files, strip React logic, and keep the Tailwind classes (as shown in Section 3).
- Adapt interactivity – Replace Radix UI primitives with Vue composables, Svelte stores, or small vanilla JS functions.
- Test for consistency – Ensure the visual output matches shadcn/ui's reference implementations across browsers and breakpoints.
Applying these steps will give you a cohesive, professional design system without being forced into React. At Paradane, we specialize in building polished cross-framework products using precisely these techniques. Visit https://paradane.com to see how we can help you scale your design system across any stack.
By following this approach, you can deliver a consistent user interface while keeping the freedom to choose the best tools for each project.
Top comments (0)