diff --git a/apps/docs/src/components/docs-tree-pathname-provider.tsx b/apps/docs/src/components/docs-tree-pathname-provider.tsx
new file mode 100644
index 00000000..ddc7f1d0
--- /dev/null
+++ b/apps/docs/src/components/docs-tree-pathname-provider.tsx
@@ -0,0 +1,51 @@
+import { Link, useParams, useRouter, useRouterState } from '@tanstack/react-router';
+import { FrameworkProvider } from 'fumadocs-core/framework';
+import type { ComponentProps, ReactNode } from 'react';
+import { useMemo, useRef } from 'react';
+import { getDocsTreePathname } from '../lib/component-page-navigation.js';
+
+const framework = {
+ Link({ href, prefetch = true, ...props }: ComponentProps<'a'> & { prefetch?: boolean }) {
+ return ;
+ },
+ usePathname() {
+ const { isLoading, pathname } = useRouterState({
+ select: (state) => ({
+ isLoading: state.isLoading,
+ pathname: state.location.pathname,
+ }),
+ });
+ const activePathname = useRef(pathname);
+ return useMemo(() => {
+ if (isLoading) return getDocsTreePathname(activePathname.current);
+ activePathname.current = pathname;
+ return getDocsTreePathname(pathname);
+ }, [isLoading, pathname]);
+ },
+ useRouter() {
+ const router = useRouter();
+ return useMemo(
+ () => ({
+ push(url: string) {
+ void router.navigate({ href: url });
+ },
+ refresh() {
+ void router.invalidate();
+ },
+ }),
+ [router],
+ );
+ },
+ useParams() {
+ return useParams({ strict: false });
+ },
+};
+
+/**
+ * Wraps Fumadocs' framework context so the sidebar tree inside `DocsLayout` matches
+ * Props pages to their Guide pathname. Props pages are hidden from the page tree, so
+ * without this remap Fumadocs falls back to the full tree instead of the Components root.
+ */
+export function DocsTreePathnameProvider({ children }: { children: ReactNode }) {
+ return {children};
+}
diff --git a/apps/docs/src/lib/component-page-navigation.test.ts b/apps/docs/src/lib/component-page-navigation.test.ts
index 05594e28..78407532 100644
--- a/apps/docs/src/lib/component-page-navigation.test.ts
+++ b/apps/docs/src/lib/component-page-navigation.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from 'vite-plus/test';
-import { getComponentPageNavigation } from './component-page-navigation.js';
+import { getComponentPageNavigation, getDocsTreePathname } from './component-page-navigation.js';
test('returns Guide and Props links for a component guide', () => {
expect(getComponentPageNavigation('/components/actions/button')).toEqual({
@@ -22,3 +22,17 @@ test('does not add component navigation to other docs pages', () => {
expect(getComponentPageNavigation('/components/actions')).toBeNull();
expect(getComponentPageNavigation('/components/actions/button/examples')).toBeNull();
});
+
+test('maps a Props pathname to its Guide pathname for tree matching', () => {
+ expect(getDocsTreePathname('/components/actions/button/props')).toBe(
+ '/components/actions/button',
+ );
+});
+
+test('leaves a Guide pathname unchanged for tree matching', () => {
+ expect(getDocsTreePathname('/components/actions/button')).toBe('/components/actions/button');
+});
+
+test('leaves other docs pathnames unchanged for tree matching', () => {
+ expect(getDocsTreePathname('/docs/installation')).toBe('/docs/installation');
+});
diff --git a/apps/docs/src/lib/component-page-navigation.ts b/apps/docs/src/lib/component-page-navigation.ts
index a477ae22..b4a551eb 100644
--- a/apps/docs/src/lib/component-page-navigation.ts
+++ b/apps/docs/src/lib/component-page-navigation.ts
@@ -18,3 +18,8 @@ export function getComponentPageNavigation(pageUrl: string): ComponentPageNaviga
propsUrl: `${guideUrl}/props`,
};
}
+
+/** Pathname Fumadocs should use for page-tree matching. Props pages map to their guide. */
+export function getDocsTreePathname(pathname: string): string {
+ return getComponentPageNavigation(pathname)?.guideUrl ?? pathname;
+}
diff --git a/apps/docs/src/lib/component-page-tree.test.ts b/apps/docs/src/lib/component-page-tree.test.ts
index 349ed882..8b88d2df 100644
--- a/apps/docs/src/lib/component-page-tree.test.ts
+++ b/apps/docs/src/lib/component-page-tree.test.ts
@@ -1,7 +1,9 @@
+import { searchPath } from 'fumadocs-core/breadcrumb';
import type { Item, Node, Root } from 'fumadocs-core/page-tree';
import { loader } from 'fumadocs-core/source';
import { expect, test } from 'vite-plus/test';
import { docs } from '../../.source/server.js';
+import { getDocsTreePathname } from './component-page-navigation.js';
const source = loader({
baseUrl: '/',
@@ -57,6 +59,34 @@ const components: ReadonlyArray<{ group: string; name: string }> = [
{ group: 'primitives', name: 'visually-hidden' },
];
+/** The root folder in a `searchPath` result, per `getBreadcrumbItemsFromPath` in `fumadocs-core/breadcrumb`. */
+function findRootFolder(path: ReadonlyArray) {
+ return path.find((node) => node.type === 'folder' && node.root);
+}
+
+test('a Props URL is hidden from the tree, so Fumadocs cannot match it directly', () => {
+ const tree = source.getPageTree();
+ const nodes = [...allNodes(tree)];
+ const propsUrl = '/components/actions/button/props';
+
+ expect(searchPath(nodes, propsUrl)).toBeNull();
+});
+
+test('remapping a Props URL to its Guide URL keeps tree matching under the Components root', () => {
+ const tree = source.getPageTree();
+ const nodes = [...allNodes(tree)];
+ const guideUrl = '/components/actions/button';
+ const propsUrl = `${guideUrl}/props`;
+
+ const guidePath = searchPath(nodes, guideUrl);
+ const remappedPath = searchPath(nodes, getDocsTreePathname(propsUrl));
+
+ expect(guidePath).not.toBeNull();
+ expect(remappedPath).not.toBeNull();
+ expect(findRootFolder(remappedPath ?? [])?.name).toBe('Components');
+ expect(findRootFolder(remappedPath ?? [])?.name).toBe(findRootFolder(guidePath ?? [])?.name);
+});
+
for (const { group, name } of components) {
test(`the sidebar entry for /components/${group}/${name} is clickable and hides its Props page`, () => {
const tree = source.getPageTree();
diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx
index cfa97aba..44eaf86d 100644
--- a/apps/docs/src/routes/$.tsx
+++ b/apps/docs/src/routes/$.tsx
@@ -9,6 +9,7 @@ import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Suspense } from 'react';
import * as z from 'zod';
import browserCollections from '../../.source/browser';
+import { DocsTreePathnameProvider } from '../components/docs-tree-pathname-provider.js';
import { ExampleBlock } from '../components/example-block';
import { IconGallery } from '../components/icon-gallery';
import { PageActions } from '../components/page-actions';
@@ -158,18 +159,20 @@ function Page() {
const data = useFumadocsLoader(Route.useLoaderData());
return (
-
-
- {clientLoader.useContent(data.path, {
- className: 'pb-16 md:pb-20 xl:pb-24',
- componentNavigation: data.componentNavigation,
- githubUrl: data.githubUrl,
- markdownUrl: data.markdownUrl,
- reactAriaUrl: data.reactAriaUrl,
- sourceUrl: data.sourceUrl,
- storybookUrl: data.storybookUrl,
- })}
-
-
+
+
+
+ {clientLoader.useContent(data.path, {
+ className: 'pb-16 md:pb-20 xl:pb-24',
+ componentNavigation: data.componentNavigation,
+ githubUrl: data.githubUrl,
+ markdownUrl: data.markdownUrl,
+ reactAriaUrl: data.reactAriaUrl,
+ sourceUrl: data.sourceUrl,
+ storybookUrl: data.storybookUrl,
+ })}
+
+
+
);
}
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
index acbc4d72..ec56d068 100644
--- a/docs/DOCUMENTATION.md
+++ b/docs/DOCUMENTATION.md
@@ -98,7 +98,9 @@ Do not document exports that are not public API.
frontmatter by `scripts/generate-props-pages.ts`. `props.mdx` is the API reference at
`/components///props`. `meta.json` uses `"pages": ["!props"]` and
`"collapsible": false` so the component stays one ordinary sidebar link. It sets
-`"pagesIndex": "../"` so the folder's sidebar entry points to the sibling guide.
+`"pagesIndex": "../"` so the folder's sidebar entry points to the sibling guide. The shared
+docs route remaps a Props pathname to its Guide URL for Fumadocs' sidebar tree matching, so Guide
+and Props share the Components sidebar.
Keep the guide and Props frontmatter titles and descriptions identical. The component generator
leaves editorial descriptions out instead of adding placeholder copy. Add one useful description to