Bridge UI

Elixir + Inertia

Set up Bridge UI in a Phoenix app with Inertia.js (Vue or React).

Bridge UI works with Phoenix through Inertia.js (inertia_phoenix).

Use the framework selector in the site header to switch between React and Vue on framework-specific steps below.

Install packages

Add Bridge UI and icons to your frontend. Packages are published on npm as 0.0.3.

bun add @bridge-ui/[email protected]
bun add @bridge-ui/[email protected]

App entry

Wire Bridge UI into your Inertia client entry:

import { createBridgeUI } from "@bridge-ui/vue";
import { createInertiaApp } from "@inertiajs/vue3";
import { createApp, h } from "vue";

createInertiaApp({
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .use(createBridgeUI())
      .mount(el);
  },
});

Bridge UI is provided from the layout—no extra plugin in the entry file:

import { createInertiaApp } from "@inertiajs/react";
import { createRoot } from "react-dom/client";

createInertiaApp({
  setup({ el, App, props }) {
    createRoot(el).render(<App {...props} />);
  },
});

Layout

Wrap your Inertia layout with BridgeUIProvider and BridgeUIHosts so imperative actions (useModalAction, useDialogAction, useSnackbarAction) work across pages.

<script setup lang="ts">
import { BridgeUIProvider } from "@bridge-ui/vue";
import { BridgeUIHosts } from "@bridge-ui/vue/Actions";
</script>

<template>
  <BridgeUIProvider :global="{}" :components="{}">
    <BridgeUIHosts
      :modal="{ modal: { transition: 'fade' } }"
      :dialog="{ modal: { transition: 'fade' } }"
      :snackbar="{ max: 3, timeout: 5000, position: 'bottom-center' }"
    >
      <slot />
    </BridgeUIHosts>
  </BridgeUIProvider>
</template>
import { BridgeUIProvider } from "@bridge-ui/react";
import { BridgeUIHosts } from "@bridge-ui/react/Actions";
import type { PropsWithChildren } from "react";

const Layout = ({ children }: PropsWithChildren) => (
  <BridgeUIProvider global={{}} components={{}}>
    <BridgeUIHosts
      modal={{ modal: { transition: "fade" } }}
      dialog={{ modal: { transition: "fade" } }}
      snackbar={{ max: 3, timeout: 5000, position: "bottom-center" }}
    >
      {children}
    </BridgeUIHosts>
  </BridgeUIProvider>
);

export default Layout;

Select async data

The Select component accepts an asyncData object with search and resolve callbacks. In Phoenix apps, these typically call JSON API endpoints via axios.

Below is a complete pattern you can copy: a router entry, a controller, and a small client helper.

Phoenix route

# router.ex
scope "/api/examples", MyAppWeb do
  get "/users", UserSelectController, :index
end

Controller

The controller returns { label, value, description } objects compatible with Select options:

# lib/my_app_web/controllers/user_select_controller.ex
defmodule MyAppWeb.UserSelectController do
  use MyAppWeb, :controller

  alias MyApp.Accounts
  alias MyApp.Accounts.User

  def index(conn, params) do
    ids = List.wrap(params["ids"]) |> Enum.reject(&(&1 in [nil, ""]))
    search = params["search"] |> to_string() |> String.trim()

    users =
      cond do
        ids != [] -> Accounts.list_users_by_ids(ids)
        search != "" -> Accounts.search_users(search, limit: 20)
        true -> Accounts.list_users(limit: 20)
      end

    json(
      conn,
      Enum.map(users, fn %User{} = user ->
        %{
          value: user.id,
          label: user.name,
          description: user.email
        }
      end)
    )
  end
end

Query parameters:

Param Purpose
search Filter options while the user types
ids Resolve labels for already-selected values

Client helper

Create assets/js/lib/asyncSelect.ts (path may vary with your asset pipeline):

import type { SelectAsyncData, SelectValue } from "@bridge-ui/core";
import axios from "axios";

export function asyncSelect(options: { url: string }): SelectAsyncData {
  const { url } = options;

  return {
    limit: 20,
    resolve: async (values: SelectValue[]) => {
      const { data } = await axios.get(url, {
        params: { ids: values },
        headers: { Accept: "application/json" },
      });

      return data;
    },
    search: async (query) => {
      const params: Record<string, string> = {};
      if (query) params.search = query;

      const { data } = await axios.get(url, {
        params,
        headers: { Accept: "application/json" },
      });

      return data;
    },
  };
}

Usage in a page

<script setup lang="ts">
import { asyncSelect } from "@/examples/core/integrations/asyncSelect";
import { Select } from "@bridge-ui/vue/Components/Select";
import { ref } from "vue";

const userId = ref(null);
</script>

<template>
  <Select
    label="User"
    v-model="userId"
    placeholder="Type to search users..."
    :async-data="asyncSelect({ url: '/api/examples/users' })"
  />
</template>
import { useState } from "react";

import { asyncSelect } from "@/examples/core/integrations/asyncSelect";
import { Select } from "@bridge-ui/react/Components/Select";

export default function UserSelect() {
  const [userId, setUserId] = useState<null | number | string>(null);

  return (
    <Select
      label="User"
      value={userId}
      onChange={setUserId}
      placeholder="Type to search users..."
      asyncData={asyncSelect({ url: "/api/examples/users" })}
    />
  );
}

asyncData implies searchable. Selected values are resolved through the same endpoint with ids when the page loads or when chips need labels.

Next steps