Copyright © ${new Date().getFullYear()} Homarr — License `,
+ copyright: `
+
+ `,
},
prism: {
theme: prismThemes.github,
@@ -287,12 +286,21 @@ const config: Config = {
function homarrPackagesPlugin() {
return {
name: "resolve-homarr-packages",
- configureWebpack() {
+ configureWebpack(_config, isServer, { getJSLoader }) {
return {
resolve: {
symlinks: false,
alias: { "@": require("path").resolve(__dirname, "src") },
},
+ module: {
+ rules: [
+ {
+ test: /\.[jt]sx?$/iu,
+ include: /node_modules[\\/]@homarr/u,
+ use: [getJSLoader({ isServer })],
+ },
+ ],
+ },
};
},
};
@@ -324,19 +332,18 @@ const config: Config = {
},
};
},
- function workshopDetailRoutePlugin() {
+ function workshopRoutesPlugin() {
return {
- name: "workshop-detail-route",
+ name: "workshop-routes",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
- component: "@site/src/components/workshop/DetailPage",
+ component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
});
},
};
},
- "@signalwire/docusaurus-plugin-llms-txt",
async function tailwindCssPlugin() {
return {
name: "docusaurus-tailwindcss",
diff --git a/apps/docs/package.json b/apps/docs/package.json
index a38e620fc..35f10f691 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -27,25 +27,37 @@
"@docusaurus/plugin-content-docs": "^3.10.1",
"@docusaurus/plugin-sitemap": "^3.10.1",
"@docusaurus/preset-classic": "^3.10.1",
+ "@homarr/custom-widgets": "workspace:^",
"@homarr/definitions": "workspace:^",
+ "@homarr/workshop": "workspace:^",
+ "@mantine/core": "catalog:",
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
- "@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
+ "@shadcn/react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.2",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
+ "cmdk": "catalog:",
+ "date-fns": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
+ "embla-carousel-react": "catalog:",
+ "input-otp": "catalog:",
"leader-line-new": "^1.1.9",
+ "next-themes": "catalog:",
"pocketbase": "catalog:",
"postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
"react": "catalog:",
+ "react-day-picker": "catalog:",
"react-dom": "catalog:",
+ "react-resizable-panels": "catalog:",
+ "recharts": "3.8.0",
+ "sonner": "catalog:",
"swiper": "^14.0.5",
"tailwind-merge": "catalog:",
"tailwindcss": "^4.3.2",
diff --git a/apps/docs/src/components/custom-jsx-component-reference.module.css b/apps/docs/src/components/custom-jsx-component-reference.module.css
new file mode 100644
index 000000000..66fe6bff0
--- /dev/null
+++ b/apps/docs/src/components/custom-jsx-component-reference.module.css
@@ -0,0 +1,451 @@
+.explorer {
+ --catalog-border: color-mix(in srgb, var(--ifm-color-emphasis-500) 42%, transparent);
+ --catalog-surface: color-mix(in srgb, var(--ifm-background-surface-color) 90%, var(--ifm-color-primary) 10%);
+ border: 1px solid var(--catalog-border);
+ border-radius: 18px;
+ margin: 1.5rem 0 2rem;
+ overflow: hidden;
+}
+
+.header {
+ align-items: flex-start;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 13%, transparent), transparent 65%);
+ display: flex;
+ gap: 1.5rem;
+ justify-content: space-between;
+ padding: 1.4rem;
+}
+
+.eyebrow {
+ color: var(--ifm-color-primary);
+ font-size: 0.72rem;
+ font-weight: 750;
+ letter-spacing: 0.09em;
+ margin: 0 0 0.35rem;
+ text-transform: uppercase;
+}
+
+.title {
+ font-size: clamp(1.25rem, 3vw, 1.65rem);
+ margin: 0;
+}
+
+.summary {
+ color: var(--ifm-color-emphasis-700);
+ margin: 0.45rem 0 0;
+ max-width: 68ch;
+}
+
+.rawLink,
+.expandButton,
+.showMoreButton,
+.resetButton {
+ align-items: center;
+ border-radius: 9px;
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ font-weight: 650;
+ justify-content: center;
+}
+
+.rawLink,
+.expandButton,
+.showMoreButton {
+ background: var(--ifm-color-primary);
+ border: 1px solid var(--ifm-color-primary);
+ color: var(--ifm-color-white);
+ padding: 0.58rem 0.85rem;
+ text-decoration: none;
+}
+
+.rawLink {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.rawLink:hover,
+.expandButton:hover,
+.showMoreButton:hover {
+ background: var(--ifm-color-primary-dark);
+ color: var(--ifm-color-white);
+ text-decoration: none;
+}
+
+.versions {
+ border-bottom: 1px solid var(--catalog-border);
+ border-top: 1px solid var(--catalog-border);
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin: 0;
+}
+
+.globalProps {
+ align-items: center;
+ border-bottom: 1px solid var(--catalog-border);
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: minmax(0, 1fr) auto;
+ padding: 1rem 1.4rem;
+ scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
+}
+
+.globalProps h4,
+.globalProps p {
+ margin: 0;
+}
+
+.globalProps p {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.86rem;
+ margin-top: 0.2rem;
+}
+
+.globalPropsTable {
+ grid-column: 1 / -1;
+}
+
+.versions div {
+ padding: 0.75rem 1.4rem;
+}
+
+.versions div + div {
+ border-left: 1px solid var(--catalog-border);
+}
+
+.versions dt {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.72rem;
+ font-weight: 650;
+ text-transform: uppercase;
+}
+
+.versions dd {
+ font-family: var(--ifm-font-family-monospace);
+ font-size: 0.86rem;
+ margin: 0.12rem 0 0;
+}
+
+.filters {
+ align-items: end;
+ background: var(--catalog-surface);
+ display: grid;
+ gap: 0.8rem;
+ grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr));
+ padding: 1.15rem 1.4rem;
+}
+
+.filters label:not(.checkboxField) {
+ display: grid;
+ gap: 0.3rem;
+}
+
+.filters label > span {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.76rem;
+ font-weight: 700;
+}
+
+.filters input[type="search"],
+.filters select {
+ background: var(--ifm-background-color);
+ border: 1px solid var(--catalog-border);
+ border-radius: 9px;
+ color: var(--ifm-font-color-base);
+ font: inherit;
+ min-height: 42px;
+ padding: 0.55rem 0.68rem;
+ width: 100%;
+}
+
+.filters input:focus-visible,
+.filters select:focus-visible,
+.expandButton:focus-visible,
+.showMoreButton:focus-visible,
+.resetButton:focus-visible,
+.anchorLink:focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
+ outline-offset: 2px;
+}
+
+.checkboxField {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ grid-column: 1 / -1;
+}
+
+.checkboxField input {
+ height: 1rem;
+ margin: 0;
+ width: 1rem;
+}
+
+.resultSummary {
+ align-items: center;
+ border-bottom: 1px solid var(--catalog-border);
+ display: flex;
+ gap: 0.6rem;
+ min-height: 48px;
+ padding: 0.7rem 1.4rem;
+}
+
+.resetButton {
+ background: transparent;
+ border: 0;
+ color: var(--ifm-color-primary);
+ margin-left: auto;
+ padding: 0.35rem;
+}
+
+.componentList {
+ display: grid;
+}
+
+.componentCard {
+ scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
+}
+
+.componentCard + .componentCard {
+ border-top: 1px solid var(--catalog-border);
+}
+
+.componentHeading {
+ align-items: center;
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ padding: 1rem 1.4rem;
+}
+
+.componentNameRow {
+ align-items: baseline;
+ display: flex;
+ gap: 0.45rem;
+}
+
+.componentNameRow h4 {
+ font-family: var(--ifm-font-family-monospace);
+ font-size: 1.05rem;
+ margin: 0;
+}
+
+.anchorLink {
+ font-weight: 700;
+ opacity: 0;
+ text-decoration: none;
+}
+
+.componentCard:hover .anchorLink,
+.anchorLink:focus-visible {
+ opacity: 1;
+}
+
+.badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.38rem;
+ margin-top: 0.45rem;
+}
+
+.badges span {
+ background: var(--ifm-color-emphasis-200);
+ border-radius: 999px;
+ color: var(--ifm-color-emphasis-800);
+ font-size: 0.7rem;
+ padding: 0.2rem 0.48rem;
+}
+
+.badges span[data-safety="allowed"] {
+ background: color-mix(in srgb, #2f9e44 18%, transparent);
+ color: color-mix(in srgb, #2f9e44 78%, var(--ifm-font-color-base));
+}
+
+.badges span[data-safety="wrapped"] {
+ background: color-mix(in srgb, var(--ifm-color-primary) 18%, transparent);
+ color: var(--ifm-color-primary-darkest);
+}
+
+.badges span[data-safety="denied"] {
+ background: color-mix(in srgb, #e03131 18%, transparent);
+ color: color-mix(in srgb, #e03131 78%, var(--ifm-font-color-base));
+}
+
+.expandButton {
+ background: transparent;
+ color: var(--ifm-color-primary);
+ flex: 0 0 auto;
+ padding: 0.42rem 0.7rem;
+}
+
+.expandButton:hover {
+ background: color-mix(in srgb, var(--ifm-color-primary) 12%, transparent);
+ color: var(--ifm-color-primary-dark);
+}
+
+.description,
+.deniedReason {
+ color: var(--ifm-color-emphasis-700);
+ margin: -0.45rem 1.4rem 1rem;
+}
+
+.deniedReason {
+ color: #c92a2a;
+}
+
+.details {
+ background: var(--catalog-surface);
+ border-top: 1px solid var(--catalog-border);
+ padding: 1.25rem 1.4rem;
+}
+
+.details h5 {
+ font-size: 0.9rem;
+ margin: 0 0 0.6rem;
+}
+
+.detailsGrid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
+ margin-bottom: 1.2rem;
+}
+
+.detailsGrid > section {
+ background: var(--ifm-background-color);
+ border: 1px solid var(--catalog-border);
+ border-radius: 12px;
+ min-width: 0;
+ padding: 0.9rem;
+}
+
+.detailsGrid p:last-of-type {
+ margin-bottom: 0.6rem;
+}
+
+.a11yList {
+ font-size: 0.86rem;
+ margin: 0 0 0.7rem;
+ padding-left: 1.1rem;
+}
+
+.tableScroll {
+ border: 1px solid var(--catalog-border);
+ border-radius: 10px;
+ max-height: 480px;
+ overflow: auto;
+}
+
+.propsTable {
+ display: table;
+ font-size: 0.78rem;
+ margin: 0;
+ width: 100%;
+}
+
+.propsTable th {
+ background: var(--ifm-background-color);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.propsTable th,
+.propsTable td {
+ min-width: 96px;
+ padding: 0.58rem;
+ vertical-align: top;
+}
+
+.propsTable td:nth-child(2) {
+ max-width: 340px;
+ min-width: 190px;
+}
+
+.type {
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.muted {
+ color: var(--ifm-color-emphasis-600);
+}
+
+.rawComponent {
+ margin-top: 1rem;
+}
+
+.rawComponent summary {
+ color: var(--ifm-color-primary);
+ cursor: pointer;
+ font-weight: 650;
+}
+
+.rawComponent > div {
+ margin-top: 0.6rem;
+}
+
+.showMoreButton {
+ display: flex;
+ margin: 1.1rem auto;
+}
+
+.loading,
+.error,
+.empty {
+ background: var(--ifm-background-surface-color);
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 12px;
+ margin: 1rem 0;
+ padding: 1rem;
+}
+
+.empty {
+ border: 0;
+ margin: 0;
+ text-align: center;
+}
+
+.error {
+ border-color: color-mix(in srgb, #e03131 58%, transparent);
+}
+
+@media (max-width: 996px) {
+ .filters {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .searchField {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 650px) {
+ .header,
+ .componentHeading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .rawLink,
+ .expandButton {
+ align-self: flex-start;
+ }
+
+ .versions,
+ .filters,
+ .detailsGrid,
+ .globalProps {
+ grid-template-columns: 1fr;
+ }
+
+ .versions div + div {
+ border-left: 0;
+ border-top: 1px solid var(--catalog-border);
+ }
+
+ .searchField,
+ .checkboxField {
+ grid-column: auto;
+ }
+}
diff --git a/apps/docs/src/components/custom-jsx-component-reference.tsx b/apps/docs/src/components/custom-jsx-component-reference.tsx
index af37a7105..23c028787 100644
--- a/apps/docs/src/components/custom-jsx-component-reference.tsx
+++ b/apps/docs/src/components/custom-jsx-component-reference.tsx
@@ -1,66 +1,497 @@
-import React from "react";
+import BrowserOnly from "@docusaurus/BrowserOnly";
+import useBaseUrl from "@docusaurus/useBaseUrl";
+import { useEffect, useMemo, useState } from "react";
-import customJsxComponentRegistry from "../generated/custom-jsx-components.json";
+import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example";
+import type {
+ CustomJsxAuthoringCatalog,
+ CustomJsxComponentApi,
+ CustomJsxPropDescriptor,
+} from "@homarr/custom-widgets/catalog";
-const enabledComponents = customJsxComponentRegistry.filter((component) => component.safety !== "denied");
-const deniedComponents = customJsxComponentRegistry.filter((component) => component.safety === "denied");
-const categories = [...new Set(enabledComponents.map((component) => component.category))];
+import { CustomWidgetCodeExample } from "./custom-widget-code";
+import styles from "./custom-jsx-component-reference.module.css";
+
+type ComponentCatalog = CustomJsxAuthoringCatalog;
+type CatalogComponent = CustomJsxComponentApi;
+type CatalogProp = CustomJsxPropDescriptor;
+
+const PAGE_SIZE = 40;
export function CustomJsxComponentReference() {
return (
- <>
- Enabled and wrapped components
-
-
-
- Category
- Components
-
-
-
- {categories.map((category) => (
-
-
- {category}
-
-
- {enabledComponents
- .filter((component) => component.category === category)
- .map((component) => (
-
-
- {component.name}
-
- {component.safety === "wrapped" && (wrapped) }
-
- ))}
-
-
- ))}
-
-
-
- Denied components
-
-
-
- Component
- Reason
-
-
-
- {deniedComponents.map((component) => (
-
-
-
- {component.name}
-
-
- {component.reason}
-
- ))}
-
-
- >
+ }>{() => }
);
}
+
+function ComponentReferenceFallback() {
+ return (
+
+ Loading the Custom JSX component catalog…
+
+ );
+}
+
+function CustomJsxComponentReferenceClient() {
+ const catalogUrl = useBaseUrl("/custom-widgets/component-catalog-v1.json");
+ const [catalog, setCatalog] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [query, setQuery] = useState("");
+ const [packageFilter, setPackageFilter] = useState("all");
+ const [categoryFilter, setCategoryFilter] = useState("all");
+ const [safetyFilter, setSafetyFilter] = useState("available");
+ const [bindableOnly, setBindableOnly] = useState(false);
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+ const [expanded, setExpanded] = useState>(() => new Set());
+ const [showGlobalProps, setShowGlobalProps] = useState(false);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoadError(null);
+
+ void fetch(catalogUrl, { signal: controller.signal })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`The catalog request returned HTTP ${response.status}.`);
+ return (await response.json()) as ComponentCatalog;
+ })
+ .then((value) => {
+ if (!Array.isArray(value.components) || !Array.isArray(value.types) || !Array.isArray(value.globalProps)) {
+ throw new Error("The catalog response has an unsupported shape.");
+ }
+ setCatalog(value);
+ })
+ .catch((error: unknown) => {
+ if (controller.signal.aborted) return;
+ setLoadError(error instanceof Error ? error.message : "The component catalog could not be loaded.");
+ });
+
+ return () => controller.abort();
+ }, [catalogUrl]);
+
+ const categories = useMemo(
+ () => [...new Set(catalog?.components.map((component) => component.category) ?? [])].sort(),
+ [catalog],
+ );
+ const packages = useMemo(
+ () => [...new Set(catalog?.components.map((component) => component.package) ?? [])].sort(),
+ [catalog],
+ );
+ const filteredComponents = useMemo(() => {
+ if (!catalog) return [];
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ return catalog.components.filter((component) => {
+ if (packageFilter !== "all" && component.package !== packageFilter) return false;
+ if (categoryFilter !== "all" && component.category !== categoryFilter) return false;
+ if (safetyFilter === "available" && component.safety === "denied") return false;
+ if (safetyFilter !== "all" && safetyFilter !== "available" && component.safety !== safetyFilter) return false;
+ if (bindableOnly && !component.bind) return false;
+ if (!normalizedQuery) return true;
+
+ return [
+ component.name,
+ component.package,
+ component.category,
+ component.description,
+ component.deniedReason,
+ ...component.subcomponents,
+ ...component.props.map((prop) => prop.name),
+ ].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery));
+ });
+ }, [bindableOnly, catalog, categoryFilter, packageFilter, query, safetyFilter]);
+ const visibleComponents = filteredComponents.slice(0, visibleCount);
+
+ useEffect(() => {
+ setVisibleCount(PAGE_SIZE);
+ }, [bindableOnly, categoryFilter, packageFilter, query, safetyFilter]);
+
+ useEffect(() => {
+ if (!catalog) return;
+
+ const revealHashTarget = () => {
+ const hash = decodeLocationHash(window.location.hash);
+ const component = catalog.components.find((entry) => componentAnchor(entry.name) === hash);
+ if (!component) return;
+
+ if (!document.getElementById(hash)) {
+ setQuery(component.name);
+ setPackageFilter("all");
+ setCategoryFilter("all");
+ setSafetyFilter(component.safety === "denied" ? "all" : "available");
+ setBindableOnly(false);
+ setVisibleCount(PAGE_SIZE);
+ }
+ setExpanded((current) => new Set([...current, component.name]));
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => document.getElementById(hash)?.scrollIntoView({ block: "start" }));
+ });
+ };
+
+ revealHashTarget();
+ window.addEventListener("hashchange", revealHashTarget);
+ return () => window.removeEventListener("hashchange", revealHashTarget);
+ }, [catalog]);
+
+ if (loadError) {
+ return (
+
+
Component catalog unavailable. {loadError}
Open the raw catalog to
+ inspect the generated data directly.
+
+ );
+ }
+
+ if (!catalog) return ;
+
+ return (
+
+
+
+
+
+
Catalog schema
+ v{catalog.schemaVersion}
+
+
+
Custom Widget
+ {catalog.customWidgetVersion}
+
+
+
Mantine
+ {catalog.mantineVersion}
+
+
+
Blocked capabilities
+ {catalog.blockedCapabilities.length}
+
+
+
+
+
+
Shared serializable props
+
+ Every available component also receives these {catalog.globalProps.length} generated global, ARIA, data, and
+ safe style props. Component cards below list only their component-specific additions.
+
+
+ setShowGlobalProps((current) => !current)}
+ >
+ {showGlobalProps ? "Hide global props" : "View global props"}
+
+ {showGlobalProps && (
+
+ )}
+
+
+
+
+ Search names, props, and subcomponents
+ setQuery(event.currentTarget.value)}
+ placeholder="For example: TextInput, bind, chart…"
+ />
+
+
+ Package
+ setPackageFilter(event.currentTarget.value)}>
+ All packages
+ {packages.map((packageName) => (
+
+ {packageName}
+
+ ))}
+
+
+
+ Category
+ setCategoryFilter(event.currentTarget.value)}>
+ All categories
+ {categories.map((category) => (
+
+ {humanize(category)}
+
+ ))}
+
+
+
+ Availability
+ setSafetyFilter(event.currentTarget.value)}>
+ Available only
+ Available and denied
+ Allowed only
+ Wrapped only
+ Denied only
+
+
+
+ setBindableOnly(event.currentTarget.checked)}
+ />
+ Supports temporary bind
+
+
+
+
+ {filteredComponents.length} matching component{filteredComponents.length === 1 ? "" : "s"}
+ {(query ||
+ packageFilter !== "all" ||
+ categoryFilter !== "all" ||
+ safetyFilter !== "available" ||
+ bindableOnly) && (
+ {
+ setQuery("");
+ setPackageFilter("all");
+ setCategoryFilter("all");
+ setSafetyFilter("available");
+ setBindableOnly(false);
+ }}
+ >
+ Reset filters
+
+ )}
+
+
+ {filteredComponents.length === 0 ? (
+ No component matches these filters.
+ ) : (
+
+ {visibleComponents.map((component) => {
+ const isExpanded = expanded.has(component.name);
+ return (
+
+
+
+
+
{component.name}
+
+ #
+
+
+
+ {component.package}
+ {humanize(component.category)}
+ {component.safety}
+ {component.bind && bind: {component.bind.type} }
+
+
+
{
+ setExpanded((current) => {
+ const next = new Set(current);
+ if (next.has(component.name)) next.delete(component.name);
+ else next.add(component.name);
+ return next;
+ });
+ }}
+ >
+ {isExpanded ? "Hide API" : "View API"}
+
+
+ {(component.description || component.deniedReason) && (
+
+ {component.deniedReason ?? component.description}
+
+ )}
+
+ {isExpanded && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {visibleCount < filteredComponents.length && (
+ setVisibleCount((current) => current + PAGE_SIZE)}
+ >
+ Show {Math.min(PAGE_SIZE, filteredComponents.length - visibleCount)} more
+
+ )}
+
+ );
+}
+
+function decodeLocationHash(hash: string) {
+ const value = hash.slice(1);
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function ComponentDetails({ id, component, types }: { id: string; component: CatalogComponent; types: string[] }) {
+ return (
+
+
+
+
+
+ Component contract
+ {component.bind ? (
+
+ bind stores a temporary {component.bind.type} input. Initialize it with{" "}
+ {component.bind.initialProp}.
+
+ ) : (
+ This component does not expose a temporary-input adapter.
+ )}
+ {component.subcomponents.length > 0 && (
+
+ Subcomponents: {" "}
+ {component.subcomponents.map((name, index) => (
+
+ {index > 0 && ", "}
+ {name}
+
+ ))}
+
+ )}
+ {component.blockedProps.length > 0 && (
+
+ {component.blockedProps.map((prop) => (
+
+ {prop.name} is blocked: {prop.reason}
+
+ ))}
+
+ )}
+ {component.accessibilityRequirements.length > 0 && (
+
+ {component.accessibilityRequirements.map((requirement) => (
+ {requirement}
+ ))}
+
+ )}
+
+ Open upstream documentation
+
+
+
+
+
+
+ Component props ({component.props.length})
+
+ {component.props.length === 0 ? (
+ No component-specific props are exposed.
+ ) : (
+
+ )}
+
+
+
+ Raw component JSON
+
+
+
+ );
+}
+
+function PropTable({ props, types }: { props: CatalogProp[]; types: string[] }) {
+ return (
+
+
+
+
+ Prop
+ Type
+ Required
+ Known values
+ Description
+
+
+
+ {props.map((prop) => (
+
+
+ {prop.name}
+
+
+ {types[prop.typeRef] ?? "unknown"}
+
+ {prop.required ? Yes : "No"}
+
+ {prop.literalValues?.length ? (
+ {prop.literalValues.map(formatLiteral).join(" | ")}
+ ) : (
+ —
+ )}
+
+ {prop.description ?? — }
+
+ ))}
+
+
+
+ );
+}
+
+function componentAnchor(name: string) {
+ return `custom-jsx-component-${name
+ .replaceAll(".", "-")
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+ .toLowerCase()}`;
+}
+
+function humanize(value: string) {
+ return value.replaceAll("-", " ").replace(/\b\w/g, (character) => character.toUpperCase());
+}
+
+function formatLiteral(value: string | number | boolean | null) {
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
+}
diff --git a/apps/docs/src/components/custom-widget-code-client.tsx b/apps/docs/src/components/custom-widget-code-client.tsx
new file mode 100644
index 000000000..ce35fc60a
--- /dev/null
+++ b/apps/docs/src/components/custom-widget-code-client.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import { useColorMode } from "@docusaurus/theme-common";
+import { MantineProvider, Select, Stack, Text } from "@mantine/core";
+/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
+import "@mantine/core/styles.layer.css";
+/* oxlint-enable import/no-unassigned-import */
+
+import {
+ BUNDLED_CUSTOM_WIDGETS,
+ CUSTOM_WIDGET_OPTIONS_EXAMPLES,
+ CUSTOM_WIDGET_REQUEST_EXAMPLES,
+} from "@homarr/custom-widgets/core";
+import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
+import { CustomWidgetCodeEditor, ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
+
+import type {
+ CanonicalCustomWidgetExampleProps,
+ CustomWidgetCodeExampleProps,
+ CustomWidgetCodeInputProps,
+} from "./custom-widget-code";
+
+const messages: CustomWidgetEditorMessages = {
+ languageJsx: "JSX",
+ languageJson: "JSON",
+ undo: "Undo",
+ redo: "Redo",
+ components: "Components",
+ componentSearch: "Search components",
+ componentEmpty: "No matching components",
+ componentCount: (count) => `${count} components`,
+ insertStarter: "Insert starter",
+ format: "Format",
+ copy: "Copy",
+ copied: "Copied",
+ schema: "Schema",
+ schemaTab: "JSON Schema",
+ minimalTab: "Minimal example",
+ fullTab: "Full example",
+ errors: (count) => `${count} errors`,
+ warnings: (count) => `${count} warnings`,
+ ready: "Read-only example",
+ position: ({ line, column }) => `Ln ${line}, Col ${column}`,
+ characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
+ diagnosticsTitle: "Diagnostics",
+ diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
+};
+const editableMessages: CustomWidgetEditorMessages = { ...messages, ready: "Ready" };
+
+export function CanonicalCustomWidgetExampleClient({ id, label, example, height }: CanonicalCustomWidgetExampleProps) {
+ const value = example === "requests" ? CUSTOM_WIDGET_REQUEST_EXAMPLES.full : CUSTOM_WIDGET_OPTIONS_EXAMPLES.full;
+
+ return ;
+}
+
+export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
+ const { colorMode } = useColorMode();
+
+ return (
+
+
+
+ );
+}
+
+export function CustomWidgetCodeInputClient(props: CustomWidgetCodeInputProps) {
+ const { colorMode } = useColorMode();
+
+ return (
+
+
+
+ );
+}
+
+export function BundledCustomWidgetGalleryClient() {
+ const { colorMode } = useColorMode();
+ const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
+ const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
+ if (!selected) return null;
+ const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
+
+ return (
+
+
+ ({ value: id, label: widget.name }))}
+ value={selected.id}
+ onChange={(value) => {
+ if (value) setSelectedId(value);
+ }}
+ allowDeselect={false}
+ />
+
+ {selected.widget.description}
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/src/components/custom-widget-code.tsx b/apps/docs/src/components/custom-widget-code.tsx
new file mode 100644
index 000000000..57777bbe7
--- /dev/null
+++ b/apps/docs/src/components/custom-widget-code.tsx
@@ -0,0 +1,82 @@
+import BrowserOnly from "@docusaurus/BrowserOnly";
+
+import {
+ BundledCustomWidgetGalleryClient,
+ CanonicalCustomWidgetExampleClient,
+ CustomWidgetCodeInputClient,
+ CustomWidgetCodeExampleClient,
+} from "./custom-widget-code-client";
+
+export interface CustomWidgetCodeExampleProps {
+ id: string;
+ label: string;
+ code: string;
+ language?: "json" | "jsx";
+ height?: string;
+}
+
+export interface CustomWidgetCodeInputProps {
+ id: string;
+ label: string;
+ value: string;
+ onChange(value: string): void;
+ language: "json" | "css";
+ description?: string;
+ placeholder?: string;
+ height?: string;
+ required?: boolean;
+ maxLength?: number;
+}
+
+export interface CanonicalCustomWidgetExampleProps {
+ id: string;
+ label: string;
+ example: "requests" | "options";
+ height?: string;
+}
+
+export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExampleProps) {
+ return (
+ }>
+ {() => }
+
+ );
+}
+
+export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
+ return (
+ }>
+ {() => }
+
+ );
+}
+
+export function CustomWidgetCodeInput(props: CustomWidgetCodeInputProps) {
+ return (
+ }>
+ {() => }
+
+ );
+}
+
+export function BundledCustomWidgetGallery() {
+ return (
+ }>
+ {() => }
+
+ );
+}
+
+function CodeExampleFallback({ height }: { height?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/docs/src/components/ui/accordion.tsx b/apps/docs/src/components/ui/accordion.tsx
new file mode 100644
index 000000000..dd991f9b5
--- /dev/null
+++ b/apps/docs/src/components/ui/accordion.tsx
@@ -0,0 +1,60 @@
+import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
+
+import { cn } from "@/lib/utils";
+import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
+
+function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
+ return ;
+}
+
+function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
+ return (
+
+ );
+}
+
+function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/apps/docs/src/components/ui/alert-dialog.tsx b/apps/docs/src/components/ui/alert-dialog.tsx
new file mode 100644
index 000000000..4cada504c
--- /dev/null
+++ b/apps/docs/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import * as React from "react";
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return ;
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return ;
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return ;
+}
+
+function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm";
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props & Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/apps/docs/src/components/ui/alert.tsx b/apps/docs/src/components/ui/alert.tsx
new file mode 100644
index 000000000..5bf519db4
--- /dev/null
+++ b/apps/docs/src/components/ui/alert.tsx
@@ -0,0 +1,40 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 [&>svg]:mt-0.5 [&>svg]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "border-border bg-card text-card-foreground",
+ success:
+ "border-emerald-600/30 bg-emerald-500/10 text-emerald-800 dark:border-emerald-400/30 dark:text-emerald-300 [&_[data-slot=alert-description]]:text-emerald-800/85 dark:[&_[data-slot=alert-description]]:text-emerald-300/85",
+ destructive:
+ "border-destructive/30 bg-destructive/10 text-destructive [&_[data-slot=alert-description]]:text-destructive/85",
+ },
+ },
+ defaultVariants: { variant: "default" },
+ },
+);
+
+function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) {
+ return
;
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertDescription, AlertTitle };
diff --git a/apps/docs/src/components/ui/aspect-ratio.tsx b/apps/docs/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 000000000..a7285fceb
--- /dev/null
+++ b/apps/docs/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,18 @@
+import { cn } from "@/lib/utils";
+
+function AspectRatio({ ratio, className, ...props }: React.ComponentProps<"div"> & { ratio: number }) {
+ return (
+
+ );
+}
+
+export { AspectRatio };
diff --git a/apps/docs/src/components/ui/attachment.tsx b/apps/docs/src/components/ui/attachment.tsx
new file mode 100644
index 000000000..5c4811cd8
--- /dev/null
+++ b/apps/docs/src/components/ui/attachment.tsx
@@ -0,0 +1,185 @@
+import * as React from "react";
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+const attachmentVariants = cva(
+ "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
+ {
+ variants: {
+ size: {
+ default:
+ "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
+ sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
+ xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
+ },
+ orientation: {
+ horizontal: "min-w-40 items-center",
+ vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
+ },
+ },
+ },
+);
+
+function Attachment({
+ className,
+ state = "done",
+ size = "default",
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps & {
+ state?: "idle" | "uploading" | "processing" | "error" | "done";
+ }) {
+ return (
+
+ );
+}
+
+const attachmentMediaVariants = cva(
+ "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
+ {
+ variants: {
+ variant: {
+ icon: "",
+ image:
+ "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
+ },
+ },
+ defaultVariants: {
+ variant: "icon",
+ },
+ },
+);
+
+function AttachmentMedia({
+ className,
+ variant = "icon",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AttachmentAction({ className, variant, size = "icon-xs", ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AttachmentTrigger({ className, render, type, ...props }: useRender.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ type: render ? type : (type ?? "button"),
+ className: cn("absolute inset-0 z-10 outline-none", className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: "attachment-trigger",
+ },
+ });
+}
+
+function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Attachment,
+ AttachmentGroup,
+ AttachmentMedia,
+ AttachmentContent,
+ AttachmentTitle,
+ AttachmentDescription,
+ AttachmentActions,
+ AttachmentAction,
+ AttachmentTrigger,
+};
diff --git a/apps/docs/src/components/ui/avatar.tsx b/apps/docs/src/components/ui/avatar.tsx
new file mode 100644
index 000000000..562ad20eb
--- /dev/null
+++ b/apps/docs/src/components/ui/avatar.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import * as React from "react";
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
+ return (
+
+ );
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ );
+}
+
+function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarFallback, AvatarImage };
diff --git a/apps/docs/src/components/ui/breadcrumb.tsx b/apps/docs/src/components/ui/breadcrumb.tsx
new file mode 100644
index 000000000..fe56c9676
--- /dev/null
+++ b/apps/docs/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,92 @@
+import * as React from "react";
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+
+import { cn } from "@/lib/utils";
+import { IconChevronRight, IconDots } from "@tabler/icons-react";
+
+function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return ;
+}
+
+function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn("transition-colors hover:text-foreground", className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: "breadcrumb-link",
+ },
+ });
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:size-4", className)}
+ {...props}
+ >
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/apps/docs/src/components/ui/bubble.tsx b/apps/docs/src/components/ui/bubble.tsx
new file mode 100644
index 000000000..ce6ce8735
--- /dev/null
+++ b/apps/docs/src/components/ui/bubble.tsx
@@ -0,0 +1,118 @@
+import * as React from "react";
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+const bubbleVariants = cva(
+ "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
+ {
+ variants: {
+ variant: {
+ default:
+ "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
+ secondary:
+ "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
+ muted:
+ "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
+ tinted:
+ "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
+ outline:
+ "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
+ ghost:
+ "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
+ destructive:
+ "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Bubble({
+ variant = "default",
+ align = "start",
+ className,
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps & {
+ align?: "start" | "end";
+ }) {
+ return (
+
+ );
+}
+
+function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: "bubble-content",
+ },
+ });
+}
+
+const bubbleReactionsVariants = cva(
+ "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
+ {
+ variants: {
+ side: {
+ top: "top-0 -translate-y-3/4",
+ bottom: "bottom-0 translate-y-3/4",
+ },
+ align: {
+ start: "left-3",
+ end: "right-3",
+ },
+ },
+ defaultVariants: {
+ side: "bottom",
+ align: "end",
+ },
+ },
+);
+
+function BubbleReactions({
+ side = "bottom",
+ align = "end",
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ align?: "start" | "end";
+ side?: "top" | "bottom";
+}) {
+ return (
+
+ );
+}
+
+export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
diff --git a/apps/docs/src/components/ui/button-group.tsx b/apps/docs/src/components/ui/button-group.tsx
new file mode 100644
index 000000000..b8cd427ac
--- /dev/null
+++ b/apps/docs/src/components/ui/button-group.tsx
@@ -0,0 +1,78 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+import { Separator } from "@/components/ui/separator";
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+ vertical:
+ "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ },
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: "button-group-text",
+ },
+ });
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
diff --git a/apps/docs/src/components/ui/calendar.tsx b/apps/docs/src/components/ui/calendar.tsx
new file mode 100644
index 000000000..aab6cc2d3
--- /dev/null
+++ b/apps/docs/src/components/ui/calendar.tsx
@@ -0,0 +1,169 @@
+import * as React from "react";
+import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from "react-day-picker";
+
+import { cn } from "@/lib/utils";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { IconChevronLeft, IconChevronRight, IconChevronDown } from "@tabler/icons-react";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ locale,
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ locale={locale}
+ formatters={{
+ formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", defaultClassNames.nav),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn("relative rounded-(--cell-radius)", defaultClassNames.dropdown_root),
+ dropdown: cn("absolute inset-0 bg-popover opacity-0", defaultClassNames.dropdown),
+ caption_label: cn(
+ "font-medium select-none",
+ captionLayout === "label"
+ ? "text-sm"
+ : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
+ defaultClassNames.caption_label,
+ ),
+ month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
+ defaultClassNames.weekday,
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
+ week_number: cn("text-[0.8rem] text-muted-foreground select-none", defaultClassNames.week_number),
+ day: cn(
+ "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn(
+ "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_end,
+ ),
+ today: cn(
+ "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
+ defaultClassNames.today,
+ ),
+ outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
+ disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return
;
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return ;
+ }
+
+ if (orientation === "right") {
+ return ;
+ }
+
+ return ;
+ },
+ DayButton: ({ ...props }) => ,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+ {children}
+
+ );
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+ span]:text-xs [&>span]:opacity-70",
+ defaultClassNames.day,
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Calendar, CalendarDayButton };
diff --git a/apps/docs/src/components/ui/carousel.tsx b/apps/docs/src/components/ui/carousel.tsx
new file mode 100644
index 000000000..eb5c95173
--- /dev/null
+++ b/apps/docs/src/components/ui/carousel.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import * as React from "react";
+import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon-sm",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+
+ Previous slide
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon-sm",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+
+ Next slide
+
+ );
+}
+
+export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel };
diff --git a/apps/docs/src/components/ui/chart.tsx b/apps/docs/src/components/ui/chart.tsx
new file mode 100644
index 000000000..1e5cd6e14
--- /dev/null
+++ b/apps/docs/src/components/ui/chart.tsx
@@ -0,0 +1,307 @@
+"use client";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+import type { TooltipValueType } from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
+type TooltipNameType = number | string;
+
+export type ChartConfig = Record<
+ string,
+ {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & ({ color?: string; theme?: never } | { color?: never; theme: Record })
+>;
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ initialDimension = INITIAL_DIMENSION,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps["children"];
+ initialDimension?: {
+ width: number;
+ height: number;
+ };
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+