diff --git a/eslint.config.ts b/eslint.config.ts
index f93cdbcd..d8e0704a 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -18,6 +18,7 @@ const config: any[] = [
"**/releases/**",
"packages/styles/dist.css",
"packages/angular/**",
+ "packages/shadcn/public",
]),
...tseslint.configs.recommended,
{
@@ -33,7 +34,7 @@ const config: any[] = [
},
{
// React package specific rules
- files: ["packages/react/src/**/*.{ts,tsx}"],
+ files: ["packages/react/src/**/*.{ts,tsx}", "packages/shadcn/src/**/*.{ts,tsx}"],
plugins: { react: pluginReact, "react-hooks": pluginReactHooks },
languageOptions: {
parserOptions: {
diff --git a/examples/shadcn/.gitignore b/examples/shadcn/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/examples/shadcn/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/examples/shadcn/README.md b/examples/shadcn/README.md
new file mode 100644
index 00000000..30404ce4
--- /dev/null
+++ b/examples/shadcn/README.md
@@ -0,0 +1 @@
+TODO
\ No newline at end of file
diff --git a/examples/shadcn/components.json b/examples/shadcn/components.json
new file mode 100644
index 00000000..86ddcae5
--- /dev/null
+++ b/examples/shadcn/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {
+ "@dev": "http://localhost:5177/{name}.json",
+ "@firebase-ui": "https://ui.firebase.com/{name}.json"
+ }
+}
diff --git a/examples/shadcn/index.html b/examples/shadcn/index.html
new file mode 100644
index 00000000..65e9db8b
--- /dev/null
+++ b/examples/shadcn/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ shadcn
+
+
+
+
+
+
diff --git a/examples/shadcn/package.json b/examples/shadcn/package.json
new file mode 100644
index 00000000..6a1807e5
--- /dev/null
+++ b/examples/shadcn/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "shadcn",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite"
+ },
+ "dependencies": {
+ "@firebase-ui/core": "workspace:*",
+ "@firebase-ui/react": "workspace:*",
+ "@hookform/resolvers": "^5.2.2",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.544.0",
+ "react": "catalog:",
+ "react-dom": "catalog:",
+ "react-hook-form": "^7.64.0",
+ "tailwind-merge": "^3.3.1",
+ "zod": "catalog:"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "catalog:",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "catalog:",
+ "tailwindcss": "catalog:",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "catalog:",
+ "vite": "catalog:"
+ }
+}
diff --git a/examples/shadcn/public/vite.svg b/examples/shadcn/public/vite.svg
new file mode 100644
index 00000000..e7b8dfb1
--- /dev/null
+++ b/examples/shadcn/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/shadcn/src/App.tsx b/examples/shadcn/src/App.tsx
new file mode 100644
index 00000000..4d63d809
--- /dev/null
+++ b/examples/shadcn/src/App.tsx
@@ -0,0 +1,5 @@
+function App() {
+ return <>TODO>;
+}
+
+export default App;
diff --git a/examples/shadcn/src/assets/react.svg b/examples/shadcn/src/assets/react.svg
new file mode 100644
index 00000000..6c87de9b
--- /dev/null
+++ b/examples/shadcn/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/shadcn/src/components/policies.tsx b/examples/shadcn/src/components/policies.tsx
new file mode 100644
index 00000000..970cf37f
--- /dev/null
+++ b/examples/shadcn/src/components/policies.tsx
@@ -0,0 +1,50 @@
+import { cn } from "@/lib/utils";
+import { getTranslation } from "@firebase-ui/core";
+import { useUI, PolicyContext } from "@firebase-ui/react";
+import { cloneElement, useContext } from "react";
+
+export function Policies() {
+ const ui = useUI();
+ const policies = useContext(PolicyContext);
+
+ if (!policies) {
+ return null;
+ }
+
+ const { termsOfServiceUrl, privacyPolicyUrl, onNavigate } = policies;
+ const termsAndPrivacyText = getTranslation(ui, "messages", "termsAndPrivacy");
+ const parts = termsAndPrivacyText.split(/(\{tos\}|\{privacy\})/);
+
+ const className = cn("hover:underline font-semibold");
+ const Handler = onNavigate ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {parts.map((part: string, index: number) => {
+ if (part === "{tos}") {
+ return cloneElement(Handler, {
+ key: index,
+ onClick: onNavigate ? () => onNavigate(termsOfServiceUrl) : undefined,
+ href: onNavigate ? undefined : termsOfServiceUrl,
+ children: getTranslation(ui, "labels", "termsOfService"),
+ });
+ }
+
+ if (part === "{privacy}") {
+ return cloneElement(Handler, {
+ key: index,
+ onClick: onNavigate ? () => onNavigate(privacyPolicyUrl) : undefined,
+ href: onNavigate ? undefined : privacyPolicyUrl,
+ children: getTranslation(ui, "labels", "privacyPolicy"),
+ });
+ }
+
+ return {part};
+ })}
+
+ );
+}
diff --git a/examples/shadcn/src/components/sign-in-auth-form.tsx b/examples/shadcn/src/components/sign-in-auth-form.tsx
new file mode 100644
index 00000000..7a46476b
--- /dev/null
+++ b/examples/shadcn/src/components/sign-in-auth-form.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import type { SignInAuthFormSchema } from "@firebase-ui/core";
+import { useSignInAuthFormAction, useSignInAuthFormSchema, useUI, SignInAuthFormProps } from "@firebase-ui/react";
+import { useForm } from "react-hook-form";
+import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
+import { FirebaseUIError, getTranslation } from "@firebase-ui/core";
+
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Policies } from "./policies";
+
+export type { SignInAuthFormProps };
+
+export function SignInAuthForm(props: SignInAuthFormProps) {
+ const ui = useUI();
+ const schema = useSignInAuthFormSchema();
+ const action = useSignInAuthFormAction();
+
+ const form = useForm({
+ resolver: standardSchemaResolver(schema),
+ defaultValues: {
+ email: "",
+ password: "",
+ },
+ });
+
+ async function onSubmit(values: SignInAuthFormSchema) {
+ try {
+ const credential = await action(values);
+ props.onSignIn?.(credential);
+ } catch (error) {
+ const message = error instanceof FirebaseUIError ? error.message : String(error);
+ form.setError("root", { message });
+ }
+ }
+
+ return (
+
+
+ );
+}
diff --git a/examples/shadcn/src/components/ui/button.tsx b/examples/shadcn/src/components/ui/button.tsx
new file mode 100644
index 00000000..1ee14790
--- /dev/null
+++ b/examples/shadcn/src/components/ui/button.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return ;
+}
+
+export { Button, buttonVariants };
diff --git a/examples/shadcn/src/components/ui/form.tsx b/examples/shadcn/src/components/ui/form.tsx
new file mode 100644
index 00000000..018ddba2
--- /dev/null
+++ b/examples/shadcn/src/components/ui/form.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { Slot } from "@radix-ui/react-slot";
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ useFormState,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form";
+
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+
+const Form = FormProvider;
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName;
+};
+
+const FormFieldContext = React.createContext({} as FormFieldContextValue);
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ );
+};
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState } = useFormContext();
+ const formState = useFormState({ name: fieldContext.name });
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ");
+ }
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+type FormItemContextValue = {
+ id: string;
+};
+
+const FormItemContext = React.createContext({} as FormItemContextValue);
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+}
+
+function FormLabel({ className, ...props }: React.ComponentProps) {
+ const { error, formItemId } = useFormField();
+
+ return (
+
+ );
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
+
+ return (
+
+ );
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message ?? "") : props.children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+}
+
+export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
diff --git a/examples/shadcn/src/components/ui/input.tsx b/examples/shadcn/src/components/ui/input.tsx
new file mode 100644
index 00000000..868dec6c
--- /dev/null
+++ b/examples/shadcn/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/examples/shadcn/src/components/ui/label.tsx b/examples/shadcn/src/components/ui/label.tsx
new file mode 100644
index 00000000..a1c0ab94
--- /dev/null
+++ b/examples/shadcn/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+
+import { cn } from "@/lib/utils";
+
+function Label({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/examples/shadcn/src/index.css b/examples/shadcn/src/index.css
new file mode 100644
index 00000000..7550e245
--- /dev/null
+++ b/examples/shadcn/src/index.css
@@ -0,0 +1,120 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/examples/shadcn/src/lib/utils.ts b/examples/shadcn/src/lib/utils.ts
new file mode 100644
index 00000000..a5ef1935
--- /dev/null
+++ b/examples/shadcn/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/examples/shadcn/src/main.tsx b/examples/shadcn/src/main.tsx
new file mode 100644
index 00000000..10ed13e0
--- /dev/null
+++ b/examples/shadcn/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App.tsx";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/examples/shadcn/tsconfig.json b/examples/shadcn/tsconfig.json
new file mode 100644
index 00000000..40f75883
--- /dev/null
+++ b/examples/shadcn/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "types": ["vite/client"],
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/examples/shadcn/vite.config.ts b/examples/shadcn/vite.config.ts
new file mode 100644
index 00000000..bc96425b
--- /dev/null
+++ b/examples/shadcn/vite.config.ts
@@ -0,0 +1,14 @@
+import path from "node:path";
+import { defineConfig } from "vite";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});
diff --git a/package.json b/package.json
index a6c258c7..dc3af672 100644
--- a/package.json
+++ b/package.json
@@ -9,15 +9,17 @@
"build:translations": "pnpm --filter=@firebase-ui/translations run build",
"build:react": "pnpm --filter=@firebase-ui/react run build",
"build:angular": "pnpm --filter=@firebase-ui/angular run build",
+ "build:shadcn": "pnpm --filter=@firebase-ui/shadcn run build",
"lint:check": "eslint",
"format:check": "prettier --check **/{src,tests}/**/*.{ts,tsx}",
"format:write": "prettier --write **/{src,tests}/**/*.{ts,tsx}",
- "test": "pnpm run test:core && pnpm run test:react && pnpm run test:translations && pnpm run test:styles",
+ "test": "pnpm run test:core && pnpm run test:react && pnpm run test:translations && pnpm run test:styles && pnpm run test:shadcn",
"test:core": "pnpm --filter=@firebase-ui/core run test",
"test:react": "pnpm --filter=@firebase-ui/react run test",
"test:angular": "pnpm --filter=@firebase-ui/angular run test",
"test:translations": "pnpm --filter=@firebase-ui/translations run test",
"test:styles": "pnpm --filter=@firebase-ui/styles run test",
+ "test:shadcn": "pnpm --filter=@firebase-ui/shadcn run test",
"test:watch": "pnpm run test:core:watch & pnpm run test:react:watch & pnpm run test:angular:watch",
"test:core:watch": "pnpm --filter=@firebase-ui/core run test:unit:watch",
"test:react:watch": "pnpm --filter=@firebase-ui/react run test:unit:watch",
diff --git a/packages/react/src/components/policies.tsx b/packages/react/src/components/policies.tsx
index 77da953e..612bcca7 100644
--- a/packages/react/src/components/policies.tsx
+++ b/packages/react/src/components/policies.tsx
@@ -26,7 +26,7 @@ export interface PolicyProps {
onNavigate?: (url: PolicyURL) => void;
}
-const PolicyContext = createContext(undefined);
+export const PolicyContext = createContext(undefined);
export function PolicyProvider({ children, policies }: { children: React.ReactNode; policies?: PolicyProps }) {
return {children};
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index a1839239..b9aca1ee 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -18,6 +18,7 @@
import { registerFramework } from "@firebase-ui/core";
import pkgJson from "../package.json";
+export { PolicyContext } from "./components/policies";
export * from "./auth";
export * from "./hooks";
export * from "./components";
diff --git a/packages/shadcn/.gitignore b/packages/shadcn/.gitignore
new file mode 100644
index 00000000..d87a0e2a
--- /dev/null
+++ b/packages/shadcn/.gitignore
@@ -0,0 +1,29 @@
+# Shadcn registry is generated by the build script
+registry.json
+# Vite builds the shadcn components during development to here
+public-dev/
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/shadcn/README.md b/packages/shadcn/README.md
new file mode 100644
index 00000000..c999969a
--- /dev/null
+++ b/packages/shadcn/README.md
@@ -0,0 +1,55 @@
+
+# Firebase UI for Web - Shadcn
+
+> This package is private and not published to npm.
+
+The `@firebase-ui/shadcn` package exposes React components via the [Shadcn Registy](https://ui.shadcn.com/docs/registry), allowing users
+to take advantage of Firebase UI for Web logic but bringing their own UI via Shadcn.
+
+To get started, add the `@firebase` registry [namespace](https://ui.shadcn.com/docs/registry/namespace) to your `components.json`:
+
+```json
+{
+ // ...
+ "registries": {
+ "@firebase": "https://ui.firebase.com/{name}.json"
+ }
+}
+```
+
+Next install one of the registry components - this will automatically install the `@firebase-ui/react` for you,
+alongwith adding any additionally required components.
+
+```bash
+npx shadcn@latest add @firebase/sign-up-auth-screen
+```
+
+Before consuming a component, ensure you have initalized your Firebase UI application:
+
+```tsx
+import { initalizeUI } from '@firebase-ui/core';
+import { FirebaseUIProvider } from '@firebase-ui/react';
+import { SignInAuthScreen } from '@/components/sign-in-auth-screen';
+
+const ui = initalizeUI(...);
+
+function App() {
+ return (
+
+
+
+ );
+}
+```
+
+## Building the registry
+
+To build the registry, run the `build` script:
+
+```
+pnpm build
+```
+
+Note, the script run (`build.ts`) expects a domain, which replaces the `{{ DOMAIN }}` field within the
+`registy-spec.json`. This enables building the registry for different domains without updating the domain
+in the actual `registry.json` file Shadcn expects.
\ No newline at end of file
diff --git a/packages/shadcn/build.ts b/packages/shadcn/build.ts
new file mode 100644
index 00000000..cd29f553
--- /dev/null
+++ b/packages/shadcn/build.ts
@@ -0,0 +1,36 @@
+import parser from "yargs-parser";
+import fs from "fs";
+import path from "path";
+import { execSync } from "child_process";
+
+const args = parser(process.argv.slice(2));
+const domain = String(args.domain);
+const publicDir = args.publicDir ? String(args.publicDir) : "public";
+const isDev = !!args.dev;
+
+if (!domain) {
+ console.error("Missing domain argument");
+ process.exit(1);
+}
+
+const registryPath = path.resolve("registry-spec.json");
+const registryRaw = fs.readFileSync(registryPath, "utf8");
+
+let replaced = registryRaw.replace(/{{\s*DOMAIN\s*}}/g, domain);
+
+// Replace dependency placeholder based on dev flag
+replaced = replaced.replace(/{{\s*DEP\s*\|\s*([^}]+)\s*}}/g, (_, packageName) => {
+ return isDev ? `${packageName.trim()}@workspace:*` : packageName.trim();
+});
+fs.writeFileSync("registry.json", replaced, "utf8");
+
+const publicRDir = path.resolve(publicDir);
+if (fs.existsSync(publicRDir)) {
+ execSync("rm -rf " + publicRDir, { stdio: "inherit" });
+}
+
+try {
+ execSync(`shadcn build -o ${publicDir}`, { stdio: "inherit" });
+} finally {
+ execSync("rm registry.json");
+}
diff --git a/packages/shadcn/components.json b/packages/shadcn/components.json
new file mode 100644
index 00000000..2b0833f0
--- /dev/null
+++ b/packages/shadcn/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json
new file mode 100644
index 00000000..c975ccc5
--- /dev/null
+++ b/packages/shadcn/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@firebase-ui/shadcn",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsx build.ts https://ui.firebase.com",
+ "preview": "vite preview",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "@firebase-ui/translations": "workspace:*",
+ "@tailwindcss/vite": "catalog:",
+ "@testing-library/jest-dom": "catalog:",
+ "@testing-library/react": "catalog:",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@types/yargs-parser": "^21.0.3",
+ "@vitejs/plugin-react": "catalog:",
+ "firebase": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:",
+ "shadcn": "2.9.3-canary.0",
+ "tailwindcss": "catalog:",
+ "tsx": "^4.20.6",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "catalog:",
+ "vite": "catalog:",
+ "vite-plugin-run": "^0.6.1",
+ "vitest": "catalog:",
+ "yargs-parser": "^22.0.0"
+ },
+ "dependencies": {
+ "@firebase-ui/core": "workspace:*",
+ "@firebase-ui/react": "workspace:*",
+ "@hookform/resolvers": "^5.2.2",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.544.0",
+ "react-hook-form": "^7.64.0",
+ "tailwind-merge": "^3.3.1",
+ "zod": "catalog:"
+ }
+}
diff --git a/packages/shadcn/public/policies.json b/packages/shadcn/public/policies.json
new file mode 100644
index 00000000..5afca182
--- /dev/null
+++ b/packages/shadcn/public/policies.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "policies",
+ "type": "registry:block",
+ "title": "Policies",
+ "description": "A component allowing users to navigate to the terms of service and privacy policy.",
+ "dependencies": [
+ "@firebase-ui/react"
+ ],
+ "files": [
+ {
+ "path": "src/registry/policies.tsx",
+ "content": "import { cn } from \"@/lib/utils\";\nimport { getTranslation } from \"@firebase-ui/core\";\nimport { useUI, PolicyContext } from \"@firebase-ui/react\";\nimport { cloneElement, useContext } from \"react\";\n\nexport function Policies() {\n const ui = useUI();\n const policies = useContext(PolicyContext);\n\n if (!policies) {\n return null;\n }\n\n const { termsOfServiceUrl, privacyPolicyUrl, onNavigate } = policies;\n const termsAndPrivacyText = getTranslation(ui, \"messages\", \"termsAndPrivacy\");\n const parts = termsAndPrivacyText.split(/(\\{tos\\}|\\{privacy\\})/);\n\n const className = cn(\"hover:underline font-semibold\");\n const Handler = onNavigate ? (\n \n ) : (\n \n );\n\n return (\n \n {parts.map((part: string, index: number) => {\n if (part === \"{tos}\") {\n return cloneElement(Handler, {\n key: index,\n onClick: onNavigate ? () => onNavigate(termsOfServiceUrl) : undefined,\n href: onNavigate ? undefined : termsOfServiceUrl,\n children: getTranslation(ui, \"labels\", \"termsOfService\"),\n });\n }\n\n if (part === \"{privacy}\") {\n return cloneElement(Handler, {\n key: index,\n onClick: onNavigate ? () => onNavigate(privacyPolicyUrl) : undefined,\n href: onNavigate ? undefined : privacyPolicyUrl,\n children: getTranslation(ui, \"labels\", \"privacyPolicy\"),\n });\n }\n\n return {part};\n })}\n
\n );\n}\n",
+ "type": "registry:component"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/packages/shadcn/public/sign-in-auth-form.json b/packages/shadcn/public/sign-in-auth-form.json
new file mode 100644
index 00000000..cbf1c522
--- /dev/null
+++ b/packages/shadcn/public/sign-in-auth-form.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "sign-in-auth-form",
+ "type": "registry:block",
+ "title": "Sign In Auth Form",
+ "description": "A form allowing users to sign in with email and password.",
+ "dependencies": [
+ "@firebase-ui/react"
+ ],
+ "registryDependencies": [
+ "input",
+ "button",
+ "form",
+ "undefined/policies.json"
+ ],
+ "files": [
+ {
+ "path": "src/registry/sign-in-auth-form.tsx",
+ "content": "\"use client\";\n\nimport type { SignInAuthFormSchema } from \"@firebase-ui/core\";\nimport { useSignInAuthFormAction, useSignInAuthFormSchema, useUI, SignInAuthFormProps } from \"@firebase-ui/react\";\nimport { useForm } from \"react-hook-form\";\nimport { standardSchemaResolver } from \"@hookform/resolvers/standard-schema\";\nimport { FirebaseUIError, getTranslation } from \"@firebase-ui/core\";\n\nimport { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Policies } from \"./policies\";\n\nexport type { SignInAuthFormProps };\n\nexport function SignInAuthForm(props: SignInAuthFormProps) {\n const ui = useUI();\n const schema = useSignInAuthFormSchema();\n const action = useSignInAuthFormAction();\n\n const form = useForm({\n resolver: standardSchemaResolver(schema),\n defaultValues: {\n email: \"\",\n password: \"\",\n },\n });\n\n async function onSubmit(values: SignInAuthFormSchema) {\n try {\n const credential = await action(values);\n props.onSignIn?.(credential);\n } catch (error) {\n const message = error instanceof FirebaseUIError ? error.message : String(error);\n form.setError(\"root\", { message });\n }\n }\n\n return (\n \n \n );\n}\n",
+ "type": "registry:component"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/packages/shadcn/registry-spec.json b/packages/shadcn/registry-spec.json
new file mode 100644
index 00000000..1f75e4fa
--- /dev/null
+++ b/packages/shadcn/registry-spec.json
@@ -0,0 +1,34 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "name": "acme",
+ "homepage": "{{ DOMAIN }}",
+ "items": [
+ {
+ "name": "sign-in-auth-form",
+ "type": "registry:block",
+ "title": "Sign In Auth Form",
+ "description": "A form allowing users to sign in with email and password.",
+ "dependencies": ["{{ DEP | @firebase-ui/react }}"],
+ "registryDependencies": ["input", "button", "form", "{{ DOMAIN }}/policies.json"],
+ "files": [
+ {
+ "path": "src/registry/sign-in-auth-form.tsx",
+ "type": "registry:component"
+ }
+ ]
+ },
+ {
+ "name": "policies",
+ "type": "registry:block",
+ "title": "Policies",
+ "description": "A component allowing users to navigate to the terms of service and privacy policy.",
+ "dependencies": ["{{ DEP | @firebase-ui/react }}"],
+ "files": [
+ {
+ "path": "src/registry/policies.tsx",
+ "type": "registry:component"
+ }
+ ]
+ }
+ ]
+}
diff --git a/packages/shadcn/setup-test.ts b/packages/shadcn/setup-test.ts
new file mode 100644
index 00000000..aa135cc6
--- /dev/null
+++ b/packages/shadcn/setup-test.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import "@testing-library/jest-dom/vitest";
diff --git a/packages/shadcn/src/components/ui/button.tsx b/packages/shadcn/src/components/ui/button.tsx
new file mode 100644
index 00000000..1ee14790
--- /dev/null
+++ b/packages/shadcn/src/components/ui/button.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return ;
+}
+
+export { Button, buttonVariants };
diff --git a/packages/shadcn/src/components/ui/field.tsx b/packages/shadcn/src/components/ui/field.tsx
new file mode 100644
index 00000000..4dcc23b3
--- /dev/null
+++ b/packages/shadcn/src/components/ui/field.tsx
@@ -0,0 +1,222 @@
+import { useMemo } from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+