Skip to content

Instantly share code, notes, and snippets.

@joaopalopes24
Last active September 28, 2026 16:11
Show Gist options
  • Select an option

  • Save joaopalopes24/3f190a138a5a657f6fb05898ab7eb85d to your computer and use it in GitHub Desktop.

Select an option

Save joaopalopes24/3f190a138a5a657f6fb05898ab7eb85d to your computer and use it in GitHub Desktop.
Bridge UI: One Design System for React and Vue

Bridge UI: One Component Library for Inertia Apps, Whether You Pick React or Vue

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.

How it works

The project is split into three packages:

  • @bridge-ui/core holds the shared logic, types, and design tokens.
  • @bridge-ui/react and @bridge-ui/vue are 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.

Setup in a Laravel + Inertia app

Install the package for your stack:

npm install @bridge-ui/react
# or
npm install @bridge-ui/vue

Import 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:

  • BridgeUIProvider holds global settings (theme, locale, adapters) and the default props for each component.
  • BridgeUIHosts enables 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.

Laravel validation errors, straight into the fields

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.

A DataTable that works with Laravel's paginator

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.

Customization

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.

What else is included

  • 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 (or bridge-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.

Links

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment