setOpen(false)}
+ />
+
+
+ {({ present }) => (
+
+ )}
+
+ >
+ );
+}
+
+export function SidebarHeader(props: ComponentProps<"div">) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function SidebarFooter(props: ComponentProps<"div">) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function SidebarViewport(props: ScrollAreaProps) {
+ return (
+
+
+ {props.children}
+
+
+ );
+}
+
+export function SidebarSeparator(props: ComponentProps<"p">) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function SidebarItem({
+ icon,
+ ...props
+}: LinkProps & {
+ icon?: ReactNode;
+}) {
+ const pathname = usePathname();
+ const active =
+ props.href !== undefined && isActive(props.href, pathname, false);
+ const { prefetch } = useInternalContext();
+
+ return (
+
+ {icon ?? (props.external ?
: null)}
+ {props.children}
+
+ );
+}
+
+export function SidebarFolder({
+ defaultOpen = false,
+ ...props
+}: ComponentProps<"div"> & {
+ defaultOpen?: boolean;
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+
+ useOnChange(defaultOpen, (v) => {
+ if (v) setOpen(v);
+ });
+
+ return (
+
+ ({ open, setOpen }), [open])}
+ >
+ {props.children}
+
+
+ );
+}
+
+export function SidebarFolderTrigger({
+ className,
+ ...props
+}: CollapsibleTriggerProps) {
+ const { open } = useFolderContext();
+
+ return (
+
+ {props.children}
+
+
+ );
+}
+
+export function SidebarFolderLink(props: LinkProps) {
+ const { open, setOpen } = useFolderContext();
+ const { prefetch } = useInternalContext();
+
+ const pathname = usePathname();
+ const active =
+ props.href !== undefined && isActive(props.href, pathname, false);
+
+ return (
+
{
+ if (
+ e.target instanceof Element &&
+ e.target.matches("[data-icon], [data-icon] *")
+ ) {
+ setOpen(!open);
+ e.preventDefault();
+ } else {
+ setOpen(active ? !open : true);
+ }
+ }}
+ prefetch={prefetch}
+ >
+ {props.children}
+
+
+ );
+}
+
+export function SidebarFolderContent(props: CollapsibleContentProps) {
+ const { level, ...ctx } = useInternalContext();
+
+ return (
+
+ ({
+ ...ctx,
+ level: level + 1,
+ }),
+ [ctx, level],
+ )}
+ >
+ {props.children}
+
+
+ );
+}
+
+export function SidebarTrigger({
+ children,
+ ...props
+}: ComponentProps<"button">) {
+ const { setOpen } = useSidebar();
+
+ return (
+
+ );
+}
+
+export function SidebarCollapseTrigger(props: ComponentProps<"button">) {
+ const { collapsed, setCollapsed } = useSidebar();
+
+ return (
+
+ );
+}
+
+function useFolderContext() {
+ const ctx = useContext(FolderContext);
+ if (!ctx) throw new Error("Missing sidebar folder");
+
+ return ctx;
+}
+
+function useInternalContext() {
+ const ctx = useContext(Context);
+ if (!ctx) throw new Error("
component required.");
+
+ return ctx;
+}
+
+export interface SidebarComponents {
+ Item: FC<{ item: PageTree.Item }>;
+ Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>;
+ Separator: FC<{ item: PageTree.Separator }>;
+}
+
+/**
+ * Render sidebar items from page tree
+ */
+export function SidebarPageTree(props: {
+ components?: Partial
;
+}) {
+ const { root } = useTreeContext();
+
+ return useMemo(() => {
+ const { Separator, Item, Folder } = props.components ?? {};
+
+ function renderSidebarList(
+ items: PageTree.Node[],
+ level: number,
+ ): ReactNode[] {
+ return items.map((item, i) => {
+ if (item.type === "separator") {
+ if (Separator) return ;
+ return (
+
+ {item.icon}
+ {item.name}
+
+ );
+ }
+
+ if (item.type === "folder") {
+ const children = renderSidebarList(item.children, level + 1);
+
+ if (Folder)
+ return (
+
+ {children}
+
+ );
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (Item) return ;
+ return (
+
+ {item.name}
+
+ );
+ });
+ }
+
+ return (
+ {renderSidebarList(root.children, 1)}
+ );
+ }, [props.components, root]);
+}
+
+function PageTreeFolder({
+ item,
+ ...props
+}: {
+ item: PageTree.Folder;
+ children: ReactNode;
+}) {
+ const { defaultOpenLevel, level } = useInternalContext();
+ const path = useTreePath();
+
+ return (
+ = level) || path.includes(item)
+ }
+ >
+ {item.index ? (
+
+ {item.icon}
+ {item.name}
+
+ ) : (
+
+ {item.icon}
+ {item.name}
+
+ )}
+ {props.children}
+
+ );
+}
diff --git a/packages/docs/src/components/layout/sidebar/index.tsx b/packages/docs/src/components/layout/sidebar/index.tsx
new file mode 100644
index 000000000..687ca16dc
--- /dev/null
+++ b/packages/docs/src/components/layout/sidebar/index.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+// Re-export all sidebar components from the main file
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarContentMobile,
+ SidebarHeader,
+ SidebarFooter,
+ SidebarViewport,
+ SidebarSeparator,
+ SidebarItem,
+ SidebarFolder,
+ SidebarFolderTrigger,
+ SidebarFolderLink,
+ SidebarFolderContent,
+ SidebarTrigger,
+ SidebarCollapseTrigger,
+ SidebarPageTree,
+ type SidebarProps,
+ type SidebarComponents,
+} from "../sidebar";
+
+// Also export contexts
+export {
+ TreeContextProvider,
+ useTreeContext,
+ useTreePath,
+} from "@/contexts/tree";
diff --git a/packages/docs/src/components/navbar.tsx b/packages/docs/src/components/navbar.tsx
new file mode 100644
index 000000000..25d0e0734
--- /dev/null
+++ b/packages/docs/src/components/navbar.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import type { ExtendedBaseLayoutProps } from "@/components/sidebar";
+import { useSidebar } from "fumadocs-ui/provider";
+import { cn } from "@/utils/cn";
+import Link from "fumadocs-core/link";
+import { useSearchContext } from "fumadocs-ui/contexts/search";
+import { Search, SidebarIcon } from "lucide-react";
+import type React from "react";
+
+interface NavbarProps extends React.ComponentProps<"header"> {
+ baseOptions?: ExtendedBaseLayoutProps;
+}
+
+export function Navbar({ baseOptions, className, ...props }: NavbarProps) {
+ const { setOpen } = useSidebar();
+ const { setOpenSearch } = useSearchContext();
+
+ const handleSidebarToggle = () => {
+ setOpen((prev) => !prev);
+ };
+
+ const handleSearchToggle = () => {
+ setOpenSearch(true);
+ };
+
+ return (
+
+ {/* Logo and title */}
+ {baseOptions?.nav?.title && (
+
+ {baseOptions.nav.title}
+
+ )}
+
+ {/* Spacer */}
+
+
+ {/* Search button */}
+ {baseOptions?.searchToggle?.enabled !== false && (
+
+ )}
+
+ {/* Sidebar toggle button */}
+
+
+ );
+}
diff --git a/packages/docs/src/components/sidebar.tsx b/packages/docs/src/components/sidebar.tsx
new file mode 100644
index 000000000..76cb7ff36
--- /dev/null
+++ b/packages/docs/src/components/sidebar.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import { SearchButton } from "@/components/SearchButton";
+import { SidebarFooterContent } from "@/components/SidebarFooterContent";
+import {
+ Sidebar as FumaSidebar,
+ SidebarCollapseTrigger,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarPageTree,
+ SidebarViewport,
+} from "@/components/layout/sidebar";
+import { useSidebar } from "fumadocs-ui/provider";
+import { TreeContextProvider } from "@/contexts/tree";
+import { cn } from "@/utils/cn";
+import Link from "fumadocs-core/link";
+import type { PageTree } from "fumadocs-core/server";
+import { useSearchContext } from "fumadocs-ui/contexts/search";
+import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
+import { Search, SidebarIcon } from "lucide-react";
+
+export interface ExtendedBaseLayoutProps
+ extends Omit {
+ githubUrl?: string;
+ github?: {
+ owner: string;
+ repo: string;
+ token?: string;
+ };
+}
+
+interface SidebarProps {
+ tree: PageTree.Root & { fallback?: PageTree.Root };
+ banner?: React.ReactNode;
+ footer?: React.ReactNode;
+ collapsible?: boolean;
+ baseOptions?: ExtendedBaseLayoutProps;
+}
+
+function CollapsibleControlInternal({
+ baseOptions,
+}: { baseOptions?: ExtendedBaseLayoutProps }) {
+ const { collapsed } = useSidebar();
+
+ return (
+ <>
+ {/* Right position for screens below 1280px */}
+
+
+
+
+ {baseOptions?.searchToggle?.enabled !== false && }
+
+
+ {/* Left position for screens 1280px and above */}
+
+
+
+
+ {baseOptions?.searchToggle?.enabled !== false && }
+
+ >
+ );
+}
+
+function SearchIconButton() {
+ const { setOpenSearch } = useSearchContext();
+
+ return (
+
+ );
+}
+
+// Custom mobile sidebar that slides from left (like desktop) but uses mobile open state
+function CustomSidebarContentMobile({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"aside">) {
+ const { open, setOpen } = useSidebar();
+ const state = open ? "open" : "closed";
+
+ return (
+ <>
+ {/* Backdrop */}
+ {open && (
+ setOpen(false)}
+ />
+ )}
+
+ >
+ );
+}
+
+export function Sidebar({
+ tree,
+ banner,
+ footer,
+ collapsible = true,
+ baseOptions,
+}: SidebarProps) {
+ // Create nav header from baseOptions
+ const navHeader = baseOptions?.nav?.title && (
+
+
+
+ {baseOptions.nav.title}
+
+ {collapsible && (
+
+
+
+ )}
+
+ {/* Add search button if enabled */}
+ {baseOptions?.searchToggle?.enabled !== false && (
+
+
+
+ )}
+
+ );
+
+ // Desktop sidebar content (collapsed state)
+ const desktopSidebar = (
+
+ {navHeader}
+ {banner && {banner}}
+
+
+
+ {footer && {footer}}
+
+
+
+
+ );
+
+ // Mobile sidebar content (open state) - slides from left like desktop but uses different state
+ const mobileSidebar = (
+
+
+
+
+ {footer && {footer}}
+
+
+
+
+ );
+
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/docs/src/components/ui/collapsible.tsx b/packages/docs/src/components/ui/collapsible.tsx
new file mode 100644
index 000000000..5be72978d
--- /dev/null
+++ b/packages/docs/src/components/ui/collapsible.tsx
@@ -0,0 +1,39 @@
+"use client";
+import { cn } from "@/utils/cn";
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
+import { forwardRef, useEffect, useState } from "react";
+
+const Collapsible = CollapsiblePrimitive.Root;
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
+
+const CollapsibleContent = forwardRef<
+ HTMLDivElement,
+ React.ComponentPropsWithoutRef
+>(({ children, ...props }, ref) => {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+});
+
+CollapsibleContent.displayName =
+ CollapsiblePrimitive.CollapsibleContent.displayName;
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/packages/docs/src/components/ui/scroll-area.tsx b/packages/docs/src/components/ui/scroll-area.tsx
new file mode 100644
index 000000000..576bc05fe
--- /dev/null
+++ b/packages/docs/src/components/ui/scroll-area.tsx
@@ -0,0 +1,58 @@
+import { cn } from "@/utils/cn";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+import * as React from "react";
+
+const ScrollArea = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+));
+
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollViewport = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+ScrollViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
+
+export { ScrollArea, ScrollBar, ScrollViewport };
diff --git a/packages/docs/src/contexts/tree.tsx b/packages/docs/src/contexts/tree.tsx
new file mode 100644
index 000000000..7c738f858
--- /dev/null
+++ b/packages/docs/src/contexts/tree.tsx
@@ -0,0 +1,55 @@
+"use client";
+import { searchPath } from "fumadocs-core/breadcrumb";
+import { createContext, usePathname } from "fumadocs-core/framework";
+import type { PageTree } from "fumadocs-core/server";
+import { type ReactNode, useMemo, useRef } from "react";
+
+type MakeRequired = Omit & Pick, K>;
+
+interface TreeContextType {
+ root: MakeRequired;
+}
+
+const TreeContext = createContext("TreeContext");
+const PathContext = createContext("PathContext", []);
+
+export function TreeContextProvider(props: {
+ tree: PageTree.Root & { fallback?: PageTree.Root };
+ children: ReactNode;
+}) {
+ const nextIdRef = useRef(0);
+ const pathname = usePathname();
+
+ // I found that object-typed props passed from a RSC will be re-constructed, hence breaking all hooks' dependencies
+ // using the id here to make sure this never happens
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const tree = useMemo(() => props.tree, [props.tree.$id ?? props.tree]);
+ const path = useMemo(() => {
+ let result = searchPath(tree.children, pathname);
+ if (result) return result;
+
+ if ("fallback" in tree && tree.fallback)
+ result = searchPath(tree.fallback.children, pathname);
+ return result ?? [];
+ }, [tree, pathname]);
+
+ const root =
+ path.findLast((item) => item.type === "folder" && item.root) ?? tree;
+ root.$id ??= String(nextIdRef.current++);
+
+ return (
+ ({ root }) as TreeContextType, [root])}
+ >
+ {props.children}
+
+ );
+}
+
+export function useTreePath(): PageTree.Node[] {
+ return PathContext.use();
+}
+
+export function useTreeContext(): TreeContextType {
+ return TreeContext.use("You must wrap this component under ");
+}
diff --git a/packages/docs/src/hooks/useGitHubStars.ts b/packages/docs/src/hooks/useGitHubStars.ts
new file mode 100644
index 000000000..359fa54ac
--- /dev/null
+++ b/packages/docs/src/hooks/useGitHubStars.ts
@@ -0,0 +1,79 @@
+import { useEffect, useState } from "react";
+
+export function useGitHubStars(owner: string, repo: string) {
+ const [stars, setStars] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const cacheKey = `github-stars-${owner}-${repo}`;
+ const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
+
+ const fetchStars = async () => {
+ try {
+ // Check localStorage cache first
+ const cached = localStorage.getItem(cacheKey);
+ if (cached) {
+ const { data, timestamp } = JSON.parse(cached);
+ if (Date.now() - timestamp < CACHE_DURATION) {
+ setStars(data);
+ setLoading(false);
+ return;
+ }
+ }
+
+ // Fetch from GitHub API
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}`,
+ {
+ headers: {
+ "User-Agent": "pochi-docs",
+ Accept: "application/vnd.github.v3+json",
+ },
+ },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ const starCount = data.stargazers_count;
+
+ // Cache in localStorage
+ localStorage.setItem(
+ cacheKey,
+ JSON.stringify({
+ data: starCount,
+ timestamp: Date.now(),
+ }),
+ );
+
+ setStars(starCount);
+ } else {
+ // If API fails, try to use cached data even if expired
+ const cached = localStorage.getItem(cacheKey);
+ if (cached) {
+ const { data } = JSON.parse(cached);
+ setStars(data);
+ }
+ }
+ } catch (error) {
+ console.error("Failed to fetch GitHub stars:", error);
+
+ // Try to use cached data on error
+ try {
+ const cached = localStorage.getItem(cacheKey);
+ if (cached) {
+ const { data } = JSON.parse(cached);
+ setStars(data);
+ }
+ } catch (cacheError) {
+ console.error("Failed to read cache:", cacheError);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchStars();
+ }, [owner, repo]);
+
+ return { stars, loading };
+}
diff --git a/packages/docs/src/utils/cn.ts b/packages/docs/src/utils/cn.ts
new file mode 100644
index 000000000..8e473dac8
--- /dev/null
+++ b/packages/docs/src/utils/cn.ts
@@ -0,0 +1 @@
+export { twMerge as cn } from "tailwind-merge";
diff --git a/packages/docs/src/utils/get-sidebar-tabs.tsx b/packages/docs/src/utils/get-sidebar-tabs.tsx
new file mode 100644
index 000000000..fa9a2b870
--- /dev/null
+++ b/packages/docs/src/utils/get-sidebar-tabs.tsx
@@ -0,0 +1,90 @@
+import type { PageTree } from "fumadocs-core/server";
+import type { ReactNode } from "react";
+
+export interface SidebarTab {
+ /**
+ * Redirect URL of the folder, usually the index page
+ */
+ url: string;
+
+ icon?: ReactNode;
+ title: ReactNode;
+ description?: ReactNode;
+
+ /**
+ * Detect from a list of urls
+ */
+ urls?: Set;
+ unlisted?: boolean;
+}
+
+export interface GetSidebarTabsOptions {
+ transform?: (option: SidebarTab, node: PageTree.Folder) => SidebarTab | null;
+}
+
+const defaultTransform: GetSidebarTabsOptions["transform"] = (option, node) => {
+ if (!node.icon) return option;
+
+ return {
+ ...option,
+ icon: (
+
+ {node.icon}
+
+ ),
+ };
+};
+
+export function getSidebarTabs(
+ tree: PageTree.Root,
+ { transform = defaultTransform }: GetSidebarTabsOptions = {},
+): SidebarTab[] {
+ const results: SidebarTab[] = [];
+
+ function scanOptions(
+ node: PageTree.Root | PageTree.Folder,
+ unlisted?: boolean,
+ ) {
+ if ("root" in node && node.root) {
+ const urls = getFolderUrls(node);
+
+ if (urls.size > 0) {
+ const option: SidebarTab = {
+ url: urls.values().next().value ?? "",
+ title: node.name,
+ icon: node.icon,
+ unlisted,
+ description: node.description,
+ urls,
+ };
+
+ const mapped = transform ? transform(option, node) : option;
+ if (mapped) results.push(mapped);
+ }
+ }
+
+ for (const child of node.children) {
+ if (child.type === "folder") scanOptions(child, unlisted);
+ }
+ }
+
+ scanOptions(tree);
+ if ("fallback" in tree && tree.fallback)
+ scanOptions(tree.fallback as PageTree.Root, true);
+
+ return results;
+}
+
+function getFolderUrls(
+ folder: PageTree.Folder,
+ output: Set = new Set(),
+): Set {
+ if (folder.index) output.add(folder.index.url);
+
+ for (const child of folder.children) {
+ if (child.type === "page" && !child.external) output.add(child.url);
+ if (child.type === "folder") getFolderUrls(child, output);
+ }
+
+ return output;
+}
diff --git a/packages/docs/src/utils/is-active.ts b/packages/docs/src/utils/is-active.ts
new file mode 100644
index 000000000..d3c288dbb
--- /dev/null
+++ b/packages/docs/src/utils/is-active.ts
@@ -0,0 +1,23 @@
+import type { SidebarTab } from "@/utils/get-sidebar-tabs";
+
+function normalize(url: string) {
+ if (url.length > 1 && url.endsWith("/")) return url.slice(0, -1);
+ return url;
+}
+
+export function isActive(
+ url: string,
+ pathname: string,
+ nested = true,
+): boolean {
+ url = normalize(url);
+ pathname = normalize(pathname);
+
+ return url === pathname || (nested && pathname.startsWith(`${url}/`));
+}
+
+export function isTabActive(tab: SidebarTab, pathname: string) {
+ if (tab.urls) return tab.urls.has(normalize(pathname));
+
+ return isActive(tab.url, pathname, true);
+}