Inertia lets Laravel developers choose React or Vue for the frontend. What it doesn't give you is a component library that works the same way in both. Teams that run one app in React and another in Vue usually end up maintaining two design systems that slowly drift apart.
Bridge UI is an open-source (MIT) component library that ships the same components, props, and design tokens for React and Vue. It's built with TypeScript and Tailwind CSS v4 and has more than 70 components: form fields, date and time pickers, overlays, and a server-friendly DataTable.
The project is split into three packages:
@bridge-ui/coreholds the shared logic, types, and design tokens.@bridge-ui/reactand@bridge-ui/vueare idiomatic implementations on top of that core.
A Button or TextField takes the same props in both frameworks. Each package still follows its framework's conventions: React uses value and onChange, and Vue uses v-model and emits.
Install the package for your stack:
npm install @bridge-ui/react
# or
npm install @bridge-ui/vueImport the theme into your Tailwind v4 stylesheet (resources/css/app.css):
@import "tailwindcss";
@import "@bridge-ui/react/theme.css"; /* or @bridge-ui/vue/theme.css */Then create a provider component that keeps all the Bridge setup in one place. It combines two pieces:
BridgeUIProviderholds global settings (theme, locale, adapters) and the default props for each component.BridgeUIHostsenables the imperative actions shown later (snackbars, modals, dialogs, drawers).
React (resources/js/Providers/BridgeProvider.tsx):
import { BridgeUIHosts } from "@bridge-ui/react/Actions";
import { BridgeUIProvider } from "@bridge-ui/react/Provider";
import type { ReactNode } from "react";
export default function BridgeProvider({ children }: { children: ReactNode }) {
return (
<BridgeUIProvider global={{ locale: "en-US" }}>
<BridgeUIHosts>{children}</BridgeUIHosts>
</BridgeUIProvider>
);
}Vue (resources/js/Providers/BridgeProvider.vue):
<script setup lang="ts">
import { BridgeUIHosts } from "@bridge-ui/vue/Actions";
import { BridgeUIProvider } from "@bridge-ui/vue/Provider";
</script>
<template>
<BridgeUIProvider :global="{ locale: 'en-US' }">
<BridgeUIHosts>
<slot />
</BridgeUIHosts>
</BridgeUIProvider>
</template>Wrap the Inertia app with it.
React (resources/js/app.tsx):
import { createInertiaApp } from "@inertiajs/react";
import { createRoot } from "react-dom/client";
import BridgeProvider from "./Providers/BridgeProvider";
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob("./Pages/**/*.tsx", { eager: true });
return pages[`./Pages/${name}.tsx`];
},
setup({ el, App, props }) {
createRoot(el).render(
<BridgeProvider>
<App {...props} />
</BridgeProvider>,
);
},
});Vue (resources/js/app.ts):
import { createInertiaApp } from "@inertiajs/vue3";
import { createApp, h } from "vue";
import BridgeProvider from "./Providers/BridgeProvider.vue";
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob("./Pages/**/*.vue", { eager: true });
return pages[`./Pages/${name}.vue`];
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(BridgeProvider, null, () => h(App, props)) })
.use(plugin)
.mount(el);
},
});You can also render the provider inside a persistent Inertia layout instead of the app entry. Either way, the configuration lives in a single file.
Here is the same "create project" form in both frameworks. It uses Inertia's useForm, so Laravel validation errors land on the field and a snackbar confirms success.
React:
import { useForm } from "@inertiajs/react";
import { useSnackbarAction } from "@bridge-ui/react/Actions";
import { Button } from "@bridge-ui/react/Components/Button";
import { TextField } from "@bridge-ui/react/Components/TextField";
export default function Create() {
const snackbar = useSnackbarAction();
const form = useForm({ name: "" });
function submit(event: React.FormEvent) {
event.preventDefault();
form.post("/projects", {
onSuccess: () => snackbar.open({ title: "Project created", color: "success" }),
});
}
return (
<form onSubmit={submit}>
<TextField
label="Project name"
value={form.data.name}
error={!!form.errors.name}
errorMessage={form.errors.name}
onChange={(event) => form.setData("name", event.target.value)}
/>
<Button type="submit" color="primary" loading={form.processing}>
Create project
</Button>
</form>
);
}Vue:
<script setup lang="ts">
import { useForm } from "@inertiajs/vue3";
import { useSnackbarAction } from "@bridge-ui/vue/Actions";
import { Button } from "@bridge-ui/vue/Components/Button";
import { TextField } from "@bridge-ui/vue/Components/TextField";
const form = useForm({ name: "" });
const snackbar = useSnackbarAction();
function submit() {
form.post("/projects", {
onSuccess: () => snackbar.open({ title: "Project created", color: "success" }),
});
}
</script>
<template>
<form v-on:submit.prevent="submit">
<TextField
v-model="form.name"
label="Project name"
:error="!!form.errors.name"
:error-message="form.errors.name"
/>
<Button type="submit" color="primary" :loading="form.processing">
Create project
</Button>
</form>
</template>The syntax follows each framework, but the props (label, error, errorMessage, loading, color) are identical. Someone moving between the two codebases doesn't have to learn a new component API.
The snackbar is triggered from code instead of being declared in the template. The same pattern exists for modals, dialogs, and drawers (useModalAction, useDialogAction, useDrawerAction), which helps when feedback has to come from many places in a large app.
DataTable doesn't fetch data itself. You pass the rows and it takes care of the interaction and the UI. That fits well with a controller returning User::paginate():
public function index()
{
return Inertia::render('Users/Index', [
'users' => User::query()->paginate(25),
]);
}<script setup lang="ts">
import { router } from "@inertiajs/vue3";
import { DataTable } from "@bridge-ui/vue/Components/DataTable";
type User = { id: number; name: string; email: string };
defineProps<{ users: { data: User[]; current_page: number; last_page: number } }>();
const columns = [
{ id: "name", header: "Name", cell: (user: User) => user.name },
{ id: "email", header: "Email", cell: (user: User) => user.email },
];
function goToPage(page: number) {
router.get("/users", { page }, { preserveState: true, preserveScroll: true });
}
</script>
<template>
<DataTable
:rows="users.data"
:columns="columns"
:page="users.current_page"
v-on:update:page="goToPage"
:page-count="users.last_page"
:get-row-id="(user) => String(user.id)"
/>
</template>Sorting, row selection, per-page selection, sticky headers, and loading states are built in and controlled the same way.
Every component reads its defaults and design tokens from the provider. To re-theme the whole library, you only edit the BridgeProvider file you created during setup, and no component needs its own wrapper:
<BridgeUIProvider
global={{ formDefaults: { size: "lg", rounded: "md" } }}
components={{
Button: { defaultProps: { variant: "outline" } },
FormField: { defaultProps: { variant: "filled" } },
}}
>
<BridgeUIHosts>{children}</BridgeUIHosts>
</BridgeUIProvider>The Vue provider takes the same global and components props. Your own sizes, colors, and variants can be added through TypeScript module augmentation, and they'll show up in autocomplete.
- Forms: TextField, Textarea, Select, Autocomplete, NumberField, PasswordField, OtpField, Checkbox, Radio, Switch, Slider, ToggleGroup, ColorPicker, FileUpload, and a TipTap-based RichTextEditor.
- Dates and times: DateField, DateRangeField, DateTimeField, TimeField, TimeRangeField, and calendars, with adapters for Day.js, date-fns, Luxon, and Moment.
- Layout and feedback: Modal, Drawer, Menu, Tabs, Accordion, Sidebar, Stepper, Alert, Snackbar, Tooltip, Progress, Skeleton, and more.
- Accessibility: keyboard navigation, focus management, and ARIA attributes are built into the components.
- Adapters: icons (Lucide, Heroicons, Tabler, Phosphor, Font Awesome) and i18n (i18next, vue-i18n).
- AI tooling:
npx bridge-ui-react-ai install(orbridge-ui-vue-ai) adds guidelines and skills to your project, so coding assistants use the real component APIs instead of guessing.
Bridge UI is currently at v0.14, so expect the API to keep evolving before 1.0. Feedback and contributions are welcome.
- Documentation: bridge-ui.com
- GitHub: github.com/Bridge-UI/core
- npm: @bridge-ui/react · @bridge-ui/vue