diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.constants.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.constants.tsx
index 3619517cea..96945fc7f9 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.constants.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.constants.tsx
@@ -1,5 +1,5 @@
-import type { StepNamesFrom } from "@/components/dialog-container/navigable-dialog";
import { CalendarClock, ChartPie, Code, Gauge, Key2 } from "@unkey/icons";
+import type { StepNamesFrom } from "@unkey/ui";
import type { SectionState } from "./types";
import { UsageSetup } from "./components/credits-setup";
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx
index 6841199a5a..ff1d893416 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx
@@ -1,12 +1,4 @@
"use client";
-import {
- NavigableDialogBody,
- NavigableDialogContent,
- NavigableDialogFooter,
- NavigableDialogHeader,
- NavigableDialogNav,
- NavigableDialogRoot,
-} from "@/components/dialog-container/navigable-dialog";
import { NavbarActionButton } from "@/components/navigation/action-button";
import { CopyableIDButton } from "@/components/navigation/copyable-id-button";
import { Navbar } from "@/components/navigation/navbar";
@@ -14,7 +6,15 @@ import { usePersistedForm } from "@/hooks/use-persisted-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus } from "@unkey/icons";
import type { IconProps } from "@unkey/icons/src/props";
-import { Button } from "@unkey/ui";
+import {
+ Button,
+ NavigableDialogBody,
+ NavigableDialogContent,
+ NavigableDialogFooter,
+ NavigableDialogHeader,
+ NavigableDialogNav,
+ NavigableDialogRoot,
+} from "@unkey/ui";
import { type FC, useEffect, useState } from "react";
import { FormProvider } from "react-hook-form";
import { toast } from "sonner";
diff --git a/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx b/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx
index 4ccd0d05f7..56920e2101 100644
--- a/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx
+++ b/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx
@@ -16,7 +16,7 @@ import { z } from "zod";
const DynamicDialogContainer = dynamic(
() =>
- import("@/components/dialog-container").then((mod) => ({
+ import("@unkey/ui").then((mod) => ({
default: mod.DialogContainer,
})),
{ ssr: false },
diff --git a/apps/engineering/content/design/components/dialog-container.mdx b/apps/engineering/content/design/components/dialog-container.mdx
new file mode 100644
index 0000000000..eeb886bc1d
--- /dev/null
+++ b/apps/engineering/content/design/components/dialog-container.mdx
@@ -0,0 +1,77 @@
+---
+title: DialogContainer
+summary: The Dialog Container is a flexible modal component that provides a consistent way to display content in a modal dialog. It's built on top of Radix UI's Dialog primitive with additional styling and functionality.
+---
+import { DialogContainerExample } from "./dialog/dialog-container.example"
+
+## Features
+
+- Accessible modal implementation
+- Customizable overlay and content styling
+- Close button with warning support
+- Keyboard navigation support
+- Customizable animations
+- Responsive design
+
+## Structure
+
+The DialogContainer is composed of three main parts:
+
+1. **Header** - Contains the title and optional subtitle
+2. **Content Area** - The main content section where children are rendered
+3. **Footer** - Optional section for actions like buttons or additional information
+
+## Styling
+
+The component comes with default styling that includes:
+
+- Responsive width and height constraints
+- Drop shadow and rounded corners
+- Overlay with backdrop blur
+- Dark mode support
+- Customizable through `className` and `contentClassName` props
+
+## Usage
+
+```tsx
+
+ Confirm
+
+ }
+>
+
Your dialog content here
+
+```
+
+### Basic Example
+
+
+
+## Props
+
+| Prop | Type | Default | Description |
+|-------------------|-------------------------|-----------|--------------------------------------------------|
+| isOpen | boolean | - | Controls the open state of the dialog |
+| onOpenChange | (value: boolean) => void | - | Callback when the open state changes |
+| title | string | - | The title of the dialog |
+| subTitle | string | - | Optional subtitle for the dialog |
+| footer | ReactNode | - | Optional footer content |
+| className | string | - | Additional classes for the dialog container |
+| contentClassName | string | - | Additional classes for the dialog content |
+| preventAutoFocus | boolean | true | Whether to prevent auto-focus on open |
+| children | ReactNode | - | The content to display in the dialog |
+
+## Accessibility
+
+The DialogContainer implements the following accessibility features:
+
+- Manages focus trap within the dialog
+- Supports keyboard navigation (Esc to close)
+- Proper ARIA attributes for screen readers
+- Focus management can be controlled via `preventAutoFocus`
diff --git a/apps/engineering/content/design/components/dialog/dialog-container.example.tsx b/apps/engineering/content/design/components/dialog/dialog-container.example.tsx
new file mode 100644
index 0000000000..b13088f160
--- /dev/null
+++ b/apps/engineering/content/design/components/dialog/dialog-container.example.tsx
@@ -0,0 +1,63 @@
+"use client";
+import { RenderComponentWithSnippet } from "@/app/components/render";
+import { DialogContainer } from "@unkey/ui";
+import { Button, Input } from "@unkey/ui";
+import { useState } from "react";
+
+export function DialogContainerExample() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const [inputResult, setInputResult] = useState("");
+
+ const handleSubmit = () => {
+ setInputResult(inputValue);
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+
+
+ setIsOpen(!isOpen)}
+ subTitle="This is an example of a subTitle. Normally used to describe the dialog"
+ title="Example Dialog Title"
+ footer={
+
+
+
+ This is an example of a footer with a button for actions needed to be done
+
+
+ }
+ >
+
+
Dialog Content
+ setInputValue(e.target.value)}
+ />
+
+
+
+ Input Result: {inputResult}
+
+
+
+
+ );
+}
diff --git a/apps/engineering/content/design/components/dialog/navigable-dialog.example.tsx b/apps/engineering/content/design/components/dialog/navigable-dialog.example.tsx
new file mode 100644
index 0000000000..8053322383
--- /dev/null
+++ b/apps/engineering/content/design/components/dialog/navigable-dialog.example.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { RenderComponentWithSnippet } from "@/app/components/render";
+import { Book2, Key } from "@unkey/icons";
+import type { IconProps } from "@unkey/icons/src/props";
+import {
+ Button,
+ NavigableDialogBody,
+ NavigableDialogContent,
+ NavigableDialogFooter,
+ NavigableDialogHeader,
+ NavigableDialogNav,
+ NavigableDialogRoot,
+} from "@unkey/ui";
+import { memo, useState } from "react";
+import type { FC } from "react";
+
+// Memoize static content to prevent unnecessary re-renders
+const TabContent = memo(({ title, description }: { title: string; description: string }) => (
+
+
+ );
+});
+
+NavigableDialogExample.displayName = "NavigableDialogExample";
diff --git a/apps/engineering/content/design/components/navigable-dialog.mdx b/apps/engineering/content/design/components/navigable-dialog.mdx
new file mode 100644
index 0000000000..f2708004d9
--- /dev/null
+++ b/apps/engineering/content/design/components/navigable-dialog.mdx
@@ -0,0 +1,122 @@
+---
+title: NavigableDialog
+summary: The Navigable Dialog is a multi-step modal component that provides a consistent way to display content with navigation between different sections. It's built on top of the Dialog Container with additional navigation capabilities.
+---
+import { NavigableDialogExample } from "./dialog/navigable-dialog.example";
+
+## Features
+
+- Multi-step navigation with sidebar
+- Icon support for navigation items
+- Accessible modal implementation
+- State management between steps
+- Customizable styling for each section
+- Keyboard navigation support
+- Responsive design
+
+## Structure
+
+The NavigableDialog is composed of several components that work together:
+
+1. **NavigableDialogRoot** - The container component that manages dialog state
+2. **NavigableDialogHeader** - Contains the title and optional subtitle
+3. **NavigableDialogBody** - Wrapper for navigation and content
+4. **NavigableDialogNav** - Sidebar navigation with icons and labels
+5. **NavigableDialogContent** - The main content area that displays the active section
+6. **NavigableDialogFooter** - Optional section for actions like buttons
+
+## Styling
+
+The component includes default styling with:
+
+- Responsive layout with side navigation
+- Smooth transitions between sections
+- Icon support in navigation items
+- Dark mode support
+- Customizable through className props
+
+## Usage
+
+```tsx
+
+
+
+
+
+ General settings content
+ },
+ {
+ id: "security",
+ content:
Security settings content
+ },
+ ]}
+ />
+
+
+
+
+
+
+```
+
+### Basic Example
+
+
+
+## Component Props
+
+### NavigableDialogRoot
+
+| Prop | Type | Default | Description |
+|----------------|-------------------------|---------|------------------------------------------------|
+| isOpen | boolean | - | Controls the open state of the dialog |
+| onOpenChange | (value: boolean) => void | - | Callback when the open state changes |
+| dialogClassName | string | - | Additional classes for the dialog container |
+| preventAutoFocus | boolean | true | Whether to prevent auto-focus on open |
+| children | ReactNode | - | The content to display in the dialog |
+
+### NavigableDialogNav
+
+| Prop | Type | Default | Description |
+|---------|-------------------------------------------|---------|--------------------------------------|
+| items | `{ id: string; label: ReactNode; icon?: FC }[]` | - | Navigation items configuration |
+| className | string | - | Additional classes for the navigation |
+| onNavigate | (fromId: string) => boolean \| `Promise`| - | Optional navigation validation |
+| initialSelectedId | string | - | Initial active section ID |
+| disabledIds | string[] | - | IDs of disabled navigation items |
+
+### NavigableDialogContent
+
+| Prop | Type | Default | Description |
+|---------|----------------------------------------|---------|--------------------------------------|
+| items | `{ id: string; content: ReactNode }[]` | - | Content items for each section |
+| className | string | - | Additional classes for content area |
+
+### NavigableDialogHeader
+
+| Prop | Type | Default | Description |
+|----------|--------|---------|--------------------------------|
+| title | string | - | The title of the dialog |
+| subTitle | string | - | Optional subtitle for the dialog|
+
+## Accessibility
+
+The NavigableDialog implements the following accessibility features:
+
+- Full keyboard navigation support
+- ARIA labels for navigation items
+- Focus management between sections
+- Screen reader announcements for section changes
+- All the accessibility features from the base Dialog component
\ No newline at end of file
diff --git a/internal/ui/package.json b/internal/ui/package.json
index c7c947d061..4cffa88dc7 100644
--- a/internal/ui/package.json
+++ b/internal/ui/package.json
@@ -20,6 +20,7 @@
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tooltip": "^1.0.7",
+ "@radix-ui/react-dialog": "^1.0.5",
"@unkey/icons": "workspace:^",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
diff --git a/internal/ui/src/components/dialog/dialog-container.tsx b/internal/ui/src/components/dialog/dialog-container.tsx
new file mode 100644
index 0000000000..c70452c12b
--- /dev/null
+++ b/internal/ui/src/components/dialog/dialog-container.tsx
@@ -0,0 +1,57 @@
+"use client";
+// biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed.
+import * as React from "react";
+import type { PropsWithChildren, ReactNode } from "react";
+import { cn } from "../../lib/utils";
+import { Dialog, DialogContent } from "./parts/dialog";
+import {
+ DefaultDialogContentArea,
+ DefaultDialogFooter,
+ DefaultDialogHeader,
+} from "./parts/dialog-parts";
+
+type DialogContainerProps = PropsWithChildren<{
+ className?: string;
+ isOpen: boolean;
+ onOpenChange: (value: boolean) => void;
+ title: string;
+ footer?: ReactNode;
+ contentClassName?: string;
+ preventAutoFocus?: boolean;
+ subTitle?: string;
+}>;
+
+export const DialogContainer = ({
+ className,
+ isOpen,
+ subTitle,
+ onOpenChange,
+ title,
+ children,
+ footer,
+ contentClassName,
+ preventAutoFocus = true,
+}: DialogContainerProps) => {
+ return (
+
+ );
+};
+
+export { DefaultDialogHeader, DefaultDialogContentArea, DefaultDialogFooter };
diff --git a/apps/dashboard/components/dialog-container/navigable-dialog.tsx b/internal/ui/src/components/dialog/navigable-dialog.tsx
similarity index 84%
rename from apps/dashboard/components/dialog-container/navigable-dialog.tsx
rename to internal/ui/src/components/dialog/navigable-dialog.tsx
index a761549130..36548b9302 100644
--- a/apps/dashboard/components/dialog-container/navigable-dialog.tsx
+++ b/internal/ui/src/components/dialog/navigable-dialog.tsx
@@ -1,11 +1,17 @@
"use client";
-import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { IconProps } from "@unkey/icons/src/props";
-import { Button } from "@unkey/ui";
-import { cn } from "@unkey/ui/src/lib/utils";
+// biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed.
+import * as React from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import type { FC, ReactNode } from "react";
-import { DefaultDialogContentArea, DefaultDialogFooter, DefaultDialogHeader } from "./dialog-parts";
+import { cn } from "../../lib/utils";
+import { Button } from "../button";
+import { Dialog, DialogContent } from "./parts/dialog";
+import {
+ DefaultDialogContentArea,
+ DefaultDialogFooter,
+ DefaultDialogHeader,
+} from "./parts/dialog-parts";
type NavigableDialogContextType = {
activeId: TStepName | undefined;
@@ -25,19 +31,19 @@ const NavigableDialogContext: React.Context> =
createNavigableDialogContext();
// Hook to use the NavigableDialog context
-export function useNavigableDialog() {
+const useNavigableDialog = () => {
const context = useContext(NavigableDialogContext) as NavigableDialogContextType;
if (context === undefined) {
throw new Error("useNavigableDialog must be used within a NavigableDialogProvider");
}
return context;
-}
+};
// Helper type to extract valid step names when using the component
export type StepNamesFrom = T[number]["id"];
// Root component that provides context and structure
-export function NavigableDialogRoot({
+const NavigableDialogRoot = ({
children,
isOpen,
onOpenChange,
@@ -49,7 +55,7 @@ export function NavigableDialogRoot({
onOpenChange: (value: boolean) => void;
dialogClassName?: string;
preventAutoFocus?: boolean;
-}) {
+}) => {
// Internal state - we'll initialize this when we get the first items from Nav
const [activeId, setActiveId] = useState();
@@ -77,26 +83,26 @@ export function NavigableDialogRoot({
);
-}
+};
// Header component
-export function NavigableDialogHeader({
+const NavigableDialogHeader = ({
title,
subTitle,
}: {
title: string;
subTitle?: string;
-}) {
+}) => {
return ;
-}
+};
// Footer component
-export function NavigableDialogFooter({ children }: { children: ReactNode }) {
+const NavigableDialogFooter = ({ children }: { children: ReactNode }) => {
return {children};
-}
+};
// Navigation sidebar component
-export function NavigableDialogNav({
+const NavigableDialogNav = ({
items,
className,
onNavigate,
@@ -114,13 +120,16 @@ export function NavigableDialogNav({
initialSelectedId?: TStepName;
disabledIds?: TStepName[];
navWidthClass?: string;
-}) {
+}) => {
const { activeId, setActiveId } = useNavigableDialog();
// Initialize activeId if it's not set and we have items
useEffect(() => {
- if (activeId === undefined && items.length > 0) {
- setActiveId(initialSelectedId ?? items[0].id);
+ const allIds = items.map((i) => i.id);
+ if (!activeId || !allIds.includes(activeId)) {
+ setActiveId(
+ initialSelectedId && allIds.includes(initialSelectedId) ? initialSelectedId : allIds[0],
+ );
}
}, [activeId, items, initialSelectedId, setActiveId]);
@@ -198,9 +207,9 @@ export function NavigableDialogNav({
})}
);
-}
+};
-export function NavigableDialogContent({
+const NavigableDialogContent = ({
items,
className,
}: {
@@ -209,7 +218,7 @@ export function NavigableDialogContent({
content: ReactNode;
}[];
className?: string;
-}) {
+}) => {
const { activeId } = useNavigableDialog();
return (
@@ -237,15 +246,25 @@ export function NavigableDialogContent({
);
-}
+};
// Main container for the nav and content
-export function NavigableDialogBody({
+const NavigableDialogBody = ({
children,
className,
}: {
children: ReactNode;
className?: string;
-}) {
+}) => {
return
{children}
;
-}
+};
+
+export {
+ NavigableDialogRoot,
+ NavigableDialogHeader,
+ NavigableDialogFooter,
+ NavigableDialogNav,
+ NavigableDialogContent,
+ NavigableDialogBody,
+ useNavigableDialog,
+};
diff --git a/internal/ui/src/components/dialog/parts/dialog-parts.tsx b/internal/ui/src/components/dialog/parts/dialog-parts.tsx
new file mode 100644
index 0000000000..bf2f815863
--- /dev/null
+++ b/internal/ui/src/components/dialog/parts/dialog-parts.tsx
@@ -0,0 +1,58 @@
+"use client";
+// biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed.
+import * as React from "react";
+import type { PropsWithChildren } from "react";
+import { cn } from "../../../lib/utils";
+import {
+ DialogFooter as ShadcnDialogFooter,
+ DialogHeader as ShadcnDialogHeader,
+ DialogTitle as ShadcnDialogTitle,
+} from "./dialog";
+
+type DefaultDialogHeaderProps = {
+ title: string;
+ subTitle?: string;
+ className?: string;
+};
+
+export const DefaultDialogHeader = ({ title, subTitle, className }: DefaultDialogHeaderProps) => {
+ return (
+
+
+ {title}
+ {subTitle && ( // Conditionally render subtitle span only if it exists
+ {subTitle}
+ )}
+
+
+ );
+};
+
+type DefaultDialogContentAreaProps = PropsWithChildren<{
+ className?: string;
+}>;
+
+export const DefaultDialogContentArea = ({
+ children,
+ className,
+}: DefaultDialogContentAreaProps) => {
+ return (
+