# pi-shadcn Full Context > A TanStack Start template for publishing a shadcn-compatible registry. This file expands the Markdown pages listed in https://pi-shadcn.vercel.app/llms.txt. Use https://pi-shadcn.vercel.app/registry.json for the machine-readable shadcn registry index. --- URL: https://pi-shadcn.vercel.app/docs.md Publish installable registry items with public docs from the same workspace. # Introduction Publish installable registry items with public docs from the same workspace. This registry publishes copy-and-paste components, blocks, hooks, files, themes, styles, fonts, bases, and universal items that can be installed with the shadcn CLI. Use the docs in this section to set up the registry, customize the site, and publish your own items. Registry docs live in `registry/docs`. Installable source lives in `registry/items`. You can update any of these files freely (or completely delete them!) --- URL: https://pi-shadcn.vercel.app/docs/installation.md Install and run the registry template locally. # Installation Install and run the registry template locally. After forking the template, install dependencies with [Vite+](https://viteplus.dev/guide/) and start the local docs app. ```sh vp install vp dev ``` Open the local URL from Vite+ to preview your docs, registry catalog, and install pages. ## Customize the Registry Update `registry/config.ts` before publishing your fork. This file controls the public registry identity, namespace, description, homepage, repository URL, and generated install URLs. ## Add Registry Items Scaffold new installable items under `registry/items/**` with the starter script. ```sh bun --bun ./scripts/new.ts --type registry:ui --name example-card --description "A compact card component." ``` You can also run `bun --bun ./scripts/new.ts` for an interactive prompt. Use `--target` for `registry:page`, `registry:file`, custom alias installs such as `@ui/ai/prompt-input.tsx`, or targeted `registry:item` files. The scaffold creates the item source, `_registry.mdx` metadata and usage docs, and an optional `_preview.tsx` preview. Public documentation pages live directly in `registry/docs/`. ## Verify Changes Run the registry doctor after item changes, check the files you touched, and build before publishing. ```sh bun --bun ./scripts/doctor.ts vp check --fix vp build ``` ## Deploy to Cloudflare Pages The build prerenders the registry site into `dist/client`, including `/registry.json`, `/r/registry.json`, every `/r/.json` item, the home page, docs pages, Markdown pages, `llms.txt`, and `sitemap.xml`. Cloudflare Pages can serve that directory directly without a Worker runtime. Use these Cloudflare Pages settings: ```text Build command: bun run build Build output directory: dist/client ``` For manual deployment: ```sh bun run build bunx wrangler pages deploy dist/client ``` Static deployments should install registry items from the explicit `/r/.json` URLs. ## Protect the Home Page Static Pages deployments cannot use a private key from application code for real access control. Protect the home page with Cloudflare Access instead: ```text Application type: Self-hosted Application domain: Path: / Policy: Allow your team, email list, or identity provider group ``` Keep registry JSON routes such as `/registry.json` and `/r/*` outside that Access rule if they should stay installable by the shadcn CLI. --- URL: https://pi-shadcn.vercel.app/docs/registry.md Scaffold and author installable registry items under registry/items. # Registry Scaffold and author installable registry items under registry/items. Registry items live under `registry/items/**` and can be installed by shadcn-compatible CLIs. Start new items with the scaffold command so the folder, `_registry.mdx`, source file, metadata, and generated registry JSON paths follow the template conventions. ```sh bun --bun ./scripts/new.ts --type registry:ui --name example-card --description "A compact card component." ``` Or, run the command without flags if you want an interactive prompt (recommended). ```sh bun --bun ./scripts/new.ts ``` [Make sure Bun is installed before running these scripts.](https://bun.com/) ## Item Types Choose the registry type from what the item installs: | Type | Use For | Folder | | -------------------- | ----------------------------------------- | ------------------------------ | | `registry:ui` | Shadcn-style UI components | `registry/items/components/**` | | `registry:component` | Components outside `components/ui` | `registry/items/components/**` | | `registry:block` | Larger composed UI patterns | `registry/items/blocks/**` | | `registry:hook` | React hooks | `registry/items/hooks/**` | | `registry:lib` | Utility or helper modules | `registry/items/lib/**` | | `registry:page` | App page files with explicit targets | `registry/items/pages/**` | | `registry:file` | Other files with explicit install targets | `registry/items/files/**` | | `registry:style` | Style-level CSS and dependency metadata | `registry/items/styles/**` | | `registry:theme` | Theme CSS variables | `registry/items/themes/**` | | `registry:font` | Font metadata | `registry/items/fonts/**` | | `registry:base` | Design system base configuration | `registry/items/bases/**` | | `registry:item` | Universal or metadata-only items | `registry/items/items/**` | The scaffold creates each item in its own folder: ```text registry/items/components/example-card/ _registry.mdx _preview.tsx example-card.tsx ``` ## CLI Flags Use flags for agent-friendly and CI-friendly scaffolding: ```sh bun --bun ./scripts/new.ts --type registry:block --name stats-panel --description "A metrics panel with reusable sample data." bun --bun ./scripts/new.ts --type registry:ui --name prompt-input --description "An AI prompt input." --target @ui/ai/prompt-input.tsx bun --bun ./scripts/new.ts --type registry:page --name dashboard-page --description "A starter dashboard page." --target app/dashboard/page.tsx bun --bun ./scripts/new.ts --type registry:file --name chart-theme --description "Shared chart theme tokens." --target styles/chart-theme.css --file-extension css bun --bun ./scripts/new.ts --type registry:font --name font-inter --description "Inter font metadata." --font-family "'Inter Variable', sans-serif" --font-import Inter --font-variable=--font-sans ``` Supported flags: - `--type`: registry item type. Defaults to `registry:ui`. - `--name`: required kebab-case item name. - `--title`: optional public title. Defaults from the name. - `--description`: required public description. - `--target`: required for `registry:page` and `registry:file`; optional for source-backed items that need a custom install path, including shadcn target placeholders like `@ui/ai/prompt-input.tsx`. - `--file-extension`: for `registry:file` and targeted `registry:item`; defaults to `ts`. - `--font-family`, `--font-import`, `--font-variable`: required for noninteractive `registry:font` scaffolds. Use `bun --bun ./scripts/new.ts --help` to print the full usage. ## Authoring Use `_registry.mdx` for public metadata and usage docs. Put the optional named `Preview` export in `_preview.tsx`. Keep both files private to authoring; never list them in `files`. For one-file `registry:ui` items, the catalog infers `.tsx`. Hooks, libs, blocks, pages, target paths, and multi-file items should list `files` explicitly. Metadata-only styles, themes, fonts, bases, and universal items can omit `files`. List authored source files with paths relative to the item `_registry.mdx` file. The catalog automatically emits `files[].target` placeholders such as `@ui/.tsx`, `@components/.tsx`, `@hooks/.ts`, and `@lib/.ts` for file types that should install through the user's `components.json` aliases. Use explicit `target` values for `registry:page`, `registry:file`, and nested alias installs like `@ui/ai/prompt-input.tsx`; do not add `registry/items/**` prefixes or a separate `sourcePath` field. Use `localRegistryDependencies` for dependencies on other local registry items. After editing an item, run `vp check --fix` on touched files and `bun --bun ./scripts/doctor.ts`. Run `vp build` when registry docs, routes, JSON output, catalog loading, or source loading changed. --- URL: https://pi-shadcn.vercel.app/docs/agents.md Give AI coding tools the right context for using and authoring this registry. # Agents Give AI coding tools the right context for using and authoring this registry. This template is designed to work well with coding agents and LLM-powered tools. It exposes Markdown-first routes for reading context, and it ships an installable Agent Skill for authoring registry items correctly. ## Install the Registry Skill Install the pi-shadcn registry authoring skill from the repository: ```sh npx skills add donotlb/pi-shadcn --skill shadcn-registry ``` Forks can install the same skill from their own repository: ```sh npx skills add / --skill shadcn-registry ``` After installing the skill, ask for registry work directly: - "add a button component to the registry" - "adapt this modal from my app into a reusable registry component" - "add a reusable hook to the registry" - "turn this dashboard section into a registry block" The skill covers all public registry item types, previews, usage docs, dependency metadata, and the scaffold command for creating new items non-interactively. ## Point Agents At Markdown The generated [`/llms.txt`](/llms.txt) route gives AI tools a compact map of the docs, registry catalog, and item pages. Use [`/llms-full.txt`](/llms-full.txt) when a tool needs expanded context with generated Markdown content inlined. Every docs page, the registry catalog, and each registry item also has a Markdown route: - [`/docs.md`](/docs.md) - [`/docs/agents.md`](/docs/agents.md) - [`/registry.md`](/registry.md) - [`/components/example-card.md`](/components/example-card.md) Inspired by [Fumadocs](https://www.fumadocs.dev/docs/headless/utils/negotiation), "human" permalinks support Markdown content negotiation too. AI clients can request `text/markdown`, `text/x-markdown`, or `text/plain` in the `Accept` header on pages like [`/docs`](/docs) or [`/components/example-card`](/components/example-card) and receive Markdown without changing URLs. Static Cloudflare Pages deployments should use the explicit `.md` routes above, because static hosts cannot negotiate Markdown and HTML from the same URL by request headers. ## Recommended Agent Workflow For registry authoring tasks, agents should: 1. Read `AGENTS.md`, this docs section, and `/llms.txt` or `/llms-full.txt` for project context. 2. Use the `shadcn-registry` skill when available. 3. Scaffold new items with `bun --bun ./scripts/new.ts --type --name --description ""`. 4. Add `--target` for `registry:page`, `registry:file`, custom alias installs like `@ui/ai/.tsx`, and targeted `registry:item` files; use `--file-extension` for `registry:file` and targeted `registry:item` files. 5. Edit the generated source, `_registry.mdx`, and `_preview.tsx` with usage docs, previews, and dependency metadata. 6. Run `vp check --fix` on touched files, `bun --bun ./scripts/doctor.ts`, and `vp build` before handoff when docs, routes, registry JSON, catalog loading, or source loading changed. --- URL: https://pi-shadcn.vercel.app/docs/cli.md Install items from this registry with the shadcn CLI. # CLI Install items from this registry with the shadcn CLI. Install a registry item with the command shown on its docs page. ```sh npx shadcn@latest add https://pi-shadcn.vercel.app/r/example-card.json ``` The command that's displayed uses the `homepage` value from `registry/config.ts` for the base URL. When deployed with a server runtime, human-facing item pages can also work with the shadcn CLI through content negotiation. For example, a browser can view [`/components/example-card`](/components/example-card), while the CLI receives the installable item JSON from the same URL: ```sh npx shadcn@latest add https://pi-shadcn.vercel.app/components/example-card ``` For static Cloudflare Pages deployments, use the explicit `/r/.json` URLs instead. Static hosts cannot choose JSON or HTML from the same URL based on request headers. Use the package manager selector on item pages to switch between npm, pnpm, yarn, bun, vite+, and deno commands. --- URL: https://pi-shadcn.vercel.app/docs/theming.md Customize the docs shell and published components with your own design tokens. # Theming Customize the docs shell and published components with your own design tokens. The docs app uses the local shadcn configuration in `components.json` and the global theme in `src/styles.css`. Published registry items are independent from the docs shell. Put installable component source in `registry/items/**`, and include any required CSS, dependencies, or registry dependencies in the item frontmatter. Use semantic tokens such as `background`, `foreground`, `primary`, `muted`, and `border` so installed components remain easy to customize. --- URL: https://pi-shadcn.vercel.app/docs/changelog.md Track user-facing changes to this registry. # Changelog Track user-facing changes to this registry. Use this page to publish notable registry updates, new items, breaking changes, and migration notes. ## v1.0.1: April 21, 2026 - Fixed the pointer cursor when hovering `Button` components. --- URL: https://pi-shadcn.vercel.app/components.md Installable UI primitives and components. # Components Installable UI primitives and components. - [Example Card](https://pi-shadcn.vercel.app/components/example-card): A compact card component that demonstrates shadcn registry dependencies. - [Overlay Scroll Container](https://pi-shadcn.vercel.app/components/overlay-scroll-container): A responsive scroll container that uses native overflow on touch devices and overlay scrollbars on desktop. --- URL: https://pi-shadcn.vercel.app/components/example-card.md A compact card component that demonstrates shadcn registry dependencies. # Example Card A compact card component that demonstrates shadcn registry dependencies. ## Installation ```bash npx shadcn@latest add https://pi-shadcn.vercel.app/r/example-card.json ``` [Registry JSON](https://pi-shadcn.vercel.app/r/example-card.json) ## Preview ```tsx import { ExampleCard } from "@/components/ui/example-card"; export function Preview() { return ; } ``` ## Source ### ui/example-card.tsx ```tsx import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; type ExampleCardProps = { title?: string; description?: string; status?: string; }; function ExampleCard({ title = "Ready to publish", description = "Use this component as a small, installable starter item.", status = "Template", }: ExampleCardProps) { return (
{title} {status}
{description}

Replace this example with a component from your own registry.

); } export { ExampleCard, type ExampleCardProps }; ``` ## Usage Use the card anywhere you need a compact content summary with a title, description, status badge, and action. ```tsx import { ExampleCard } from "@/components/ui/example-card"; ``` --- URL: https://pi-shadcn.vercel.app/components/overlay-scroll-container.md A responsive scroll container that uses native overflow on touch devices and overlay scrollbars on desktop. # Overlay Scroll Container A responsive scroll container that uses native overflow on touch devices and overlay scrollbars on desktop. ## Installation ```bash npx shadcn@latest add https://pi-shadcn.vercel.app/r/overlay-scroll-container.json ``` [Registry JSON](https://pi-shadcn.vercel.app/r/overlay-scroll-container.json) ## Preview ```tsx import { OverlayScrollContainer } from "@/components/overlay-scroll-container"; export function Preview() { return (
{Array.from({ length: 16 }, (_, index) => (
Scroll row {index + 1}
))}
); } ``` ## Source ### overlay-scroll-container.tsx ```tsx "use client"; import type { PartialOptions } from "overlayscrollbars"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import * as React from "react"; import { cn } from "@/lib/utils"; // eslint-disable-next-line import/no-unassigned-import import "overlayscrollbars/styles/overlayscrollbars.css"; const COARSE_POINTER_QUERY = "(pointer: coarse)"; const SMALL_VIEWPORT_QUERY = "(max-width: 768px)"; function detectMobileViewport() { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { return false; } return ( window.matchMedia(COARSE_POINTER_QUERY).matches || window.matchMedia(SMALL_VIEWPORT_QUERY).matches ); } function subscribeToMobileViewport(onStoreChange: () => void) { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { return () => {}; } const coarsePointerMedia = window.matchMedia(COARSE_POINTER_QUERY); const smallViewportMedia = window.matchMedia(SMALL_VIEWPORT_QUERY); coarsePointerMedia.addEventListener("change", onStoreChange); smallViewportMedia.addEventListener("change", onStoreChange); return () => { coarsePointerMedia.removeEventListener("change", onStoreChange); smallViewportMedia.removeEventListener("change", onStoreChange); }; } function useMobileViewport() { return React.useSyncExternalStore(subscribeToMobileViewport, detectMobileViewport, () => false); } export interface OverlayScrollContainerProps extends React.ComponentPropsWithoutRef<"div"> { element?: React.ElementType; enabled?: boolean; defer?: boolean; options?: PartialOptions | false | null; nativeOverflowClassName?: string; } export function OverlayScrollContainer({ element = "div", className, enabled = true, defer = true, options, nativeOverflowClassName = "overflow-auto", children, ...props }: OverlayScrollContainerProps) { const isMobile = useMobileViewport(); const shouldEnable = enabled && !isMobile; const Element = element; if (!shouldEnable) { return ( {children} ); } return ( {children} ); } ``` ## Usage A responsive scroll container that uses native overflow on touch devices and overlay scrollbars on desktop. ```tsx import { OverlayScrollContainer } from "@/components/overlay-scroll-container"; ``` --- URL: https://pi-shadcn.vercel.app/blocks.md Composed templates and larger UI sections. # Blocks Composed templates and larger UI sections. - [Auth](https://pi-shadcn.vercel.app/blocks/auth): Authentication form blocks for email/password and email one-time-code flows. - [Loading Screen](https://pi-shadcn.vercel.app/blocks/loading-screen): A full-screen branded loading overlay with progress and a minimum dwell time. - [Offset Pagination](https://pi-shadcn.vercel.app/blocks/offset-pagination): An offset-based pagination bar with page-size controls and reusable state helpers. - [Stats Panel](https://pi-shadcn.vercel.app/blocks/stats-panel): A simple multi-file block with a component and local data helper. - [Workbench App Shell](https://pi-shadcn.vercel.app/blocks/workbench-app-shell): A resizable workbench application shell with sidebar, topbar, split view, right panel, and settings primitives. --- URL: https://pi-shadcn.vercel.app/blocks/auth.md Authentication form blocks for email/password and email one-time-code flows. # Auth Authentication form blocks for email/password and email one-time-code flows. ## Installation ```bash npx shadcn@latest add https://pi-shadcn.vercel.app/r/auth.json ``` [Registry JSON](https://pi-shadcn.vercel.app/r/auth.json) ## Preview ```tsx import { useState } from "react"; import { AuthEmailPasswordBlock, AuthPageShell, AuthTermsFooter, GoogleProviderIcon } from "@/components/auth/index"; export function Preview() { const [email, setEmail] = useState("name@company.com"); const [password, setPassword] = useState(""); return (
} title="Welcome back" description="Sign in to continue managing your workspace." switchAction={} footer={} className="min-h-[560px]" > , onClick: () => undefined, }, ]} onEmailChange={setEmail} onPasswordChange={setPassword} onSubmit={() => undefined} onForgotPassword={() => undefined} onUseOtp={() => undefined} />
); } function DemoLogo() { return (
PI
); } ``` ## Source ### auth.tsx ```tsx "use client"; import { ArrowLeftIcon } from "lucide-react"; import type { FormEvent, ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import type { AuthEmailOtpLabels, AuthEmailPasswordLabels } from "@/components/auth/labels"; import { AUTH_EMAIL_OTP_DEFAULT_LABELS, AUTH_EMAIL_PASSWORD_DEFAULT_LABELS } from "@/components/auth/labels"; export type AuthMode = "signin" | "signup"; export type AuthStep = "email" | "otp"; export interface AuthProviderAction { id: string; label: string; icon?: ReactNode; onClick: () => void | Promise; disabled?: boolean; } export interface AuthPageShellProps { mode: AuthMode; brandName: string; logo: ReactNode; title: string; description: string; switchAction: ReactNode; footer?: ReactNode; children: ReactNode; className?: string; } export interface AuthEmailOtpBlockProps { mode: AuthMode; step: AuthStep; email: string; otp: string; maskedEmail?: string; emailPlaceholder?: string; isSubmitting?: boolean; isResending?: boolean; resendLabel?: string; canResend?: boolean; error?: string | null; providers?: AuthProviderAction[]; /** Override any user-facing copy. Unspecified fields fall back to English. */ labels?: Partial; onEmailChange: (value: string) => void; onOtpChange: (value: string) => void; onEmailSubmit: () => void | Promise; onOtpSubmit: () => void | Promise; onBackToEmail: () => void; onResend: () => void | Promise; className?: string; } export function AuthPageShell({ mode, brandName, logo, title, description, switchAction, footer, children, className, }: AuthPageShellProps) { return (
{switchAction}
{logo}

{title}

{description}

{children}
{footer && (
{footer}
)}
); } export function AuthEmailOtpBlock({ mode, step, email, otp, maskedEmail, emailPlaceholder = "name@work-email.com", isSubmitting, isResending, resendLabel = "Resend code", canResend = true, error, providers = [], labels, onEmailChange, onOtpChange, onEmailSubmit, onOtpSubmit, onBackToEmail, onResend, className, }: AuthEmailOtpBlockProps) { const l = { ...AUTH_EMAIL_OTP_DEFAULT_LABELS, ...labels }; const handleEmailSubmit = (event: FormEvent) => { event.preventDefault(); void onEmailSubmit(); }; const handleOtpSubmit = (event: FormEvent) => { event.preventDefault(); void onOtpSubmit(); }; return (
{error && (
{error}
)} {step === "email" ? (
onEmailChange(event.target.value)} disabled={isSubmitting} required className="h-[46px] rounded-md border-[#dedede] bg-white px-4 text-base shadow-[0_1px_1px_rgba(0,0,0,0.02)] placeholder:text-[#8a8a8a] focus-visible:ring-2" aria-label={l.emailAriaLabel} />
) : (

{l.otpSentTo}{" "} {maskedEmail || email}

onOtpChange(event.target.value)} disabled={isSubmitting} required className="h-[56px] rounded-md border-[#dedede] bg-white px-4 text-center font-mono text-2xl tracking-[0.34em] shadow-[0_1px_1px_rgba(0,0,0,0.02)] placeholder:tracking-normal placeholder:text-[#8a8a8a] focus-visible:ring-2" aria-label={l.codeAriaLabel} />
)} {step === "email" && providers.length > 0 && ( <>
)}
); } export interface AuthEmailPasswordBlockProps { mode: AuthMode; email: string; password: string; emailPlaceholder?: string; isSubmitting?: boolean; /** Sign-up only: shown after a successful registration that needs verification. */ notice?: string | null; error?: string | null; providers?: AuthProviderAction[]; /** Override any user-facing copy. Unspecified fields fall back to English. */ labels?: Partial; onEmailChange: (value: string) => void; onPasswordChange: (value: string) => void; onSubmit: () => void | Promise; /** Sign-in only: navigate to the forgot-password flow. */ onForgotPassword?: () => void; /** Switch to the email one-time-code flow. */ onUseOtp?: () => void; className?: string; } export function AuthEmailPasswordBlock({ mode, email, password, emailPlaceholder = "name@work-email.com", isSubmitting, notice, error, providers = [], labels, onEmailChange, onPasswordChange, onSubmit, onForgotPassword, onUseOtp, className, }: AuthEmailPasswordBlockProps) { const l = { ...AUTH_EMAIL_PASSWORD_DEFAULT_LABELS, ...labels }; const isSignUp = mode === "signup"; const handleSubmit = (event: FormEvent) => { event.preventDefault(); void onSubmit(); }; return (
{notice && (
{notice}
)} {error && (
{error}
)}
onEmailChange(event.target.value)} disabled={isSubmitting} required className="h-[46px] rounded-md border-[#dedede] bg-white px-4 text-base shadow-[0_1px_1px_rgba(0,0,0,0.02)] placeholder:text-[#8a8a8a] focus-visible:ring-2" aria-label={l.emailAriaLabel} /> onPasswordChange(event.target.value)} disabled={isSubmitting} required className="h-[46px] rounded-md border-[#dedede] bg-white px-4 text-base shadow-[0_1px_1px_rgba(0,0,0,0.02)] placeholder:text-[#8a8a8a] focus-visible:ring-2" aria-label={l.passwordAriaLabel} /> {!isSignUp && onForgotPassword && (
)}
{onUseOtp && ( )} {providers.length > 0 && ( <>
)}
); } export function AuthProviderList({ providers, disabled, }: { providers: AuthProviderAction[]; disabled?: boolean; }) { return (
{providers.map((provider) => ( ))}
); } export function AuthTermsFooter({ brandName, termsHref = "/terms", privacyHref = "/privacy", }: { brandName: string; termsHref?: string; privacyHref?: string; }) { return (

By proceeding, you agree to creating a {brandName} account subject to our{" "} Terms of Service {" "} and{" "} Privacy Policy .

); } export function GoogleProviderIcon({ className }: { className?: string }) { return ( ); } ``` ### auth-labels.ts ```ts /** * User-facing copy for the auth blocks. Every field has an English default, so * the blocks render standalone; consumers override these (e.g. with their i18n * library) by passing a partial `labels` object. The blocks stay i18n-agnostic — * they never import a translation library. * * These live in their own module (not alongside the components) so the component * file only exports components, keeping React Fast Refresh happy. */ export interface AuthEmailOtpLabels { continueWithEmail: string; sendingCode: string; /** Lead-in shown before the (separately styled) masked email. */ otpSentTo: string; changeEmail: string; verifying: string; createAccount: string; signIn: string; sending: string; emailAriaLabel: string; codeAriaLabel: string; } export const AUTH_EMAIL_OTP_DEFAULT_LABELS: AuthEmailOtpLabels = { continueWithEmail: "Continue with Email", sendingCode: "Sending code...", otpSentTo: "Enter the code sent to", changeEmail: "Change email", verifying: "Verifying...", createAccount: "Create account", signIn: "Sign in", sending: "Sending...", emailAriaLabel: "Email address", codeAriaLabel: "Verification code", }; export interface AuthEmailPasswordLabels { passwordPlaceholder: string; forgotPassword: string; creatingAccount: string; signingIn: string; createAccount: string; signIn: string; useOtp: string; emailAriaLabel: string; passwordAriaLabel: string; } export const AUTH_EMAIL_PASSWORD_DEFAULT_LABELS: AuthEmailPasswordLabels = { passwordPlaceholder: "Password", forgotPassword: "Forgot password?", creatingAccount: "Creating account...", signingIn: "Signing in...", createAccount: "Create account", signIn: "Sign in", useOtp: "Use a verification code instead", emailAriaLabel: "Email address", passwordAriaLabel: "Password", }; ``` ## Usage Authentication form blocks for email/password and email one-time-code flows. ```tsx import { AuthEmailPasswordBlock, AuthPageShell } from "@/components/auth"; ``` --- URL: https://pi-shadcn.vercel.app/blocks/loading-screen.md A full-screen branded loading overlay with progress and a minimum dwell time. # Loading Screen A full-screen branded loading overlay with progress and a minimum dwell time. ## Installation ```bash npx shadcn@latest add https://pi-shadcn.vercel.app/r/loading-screen.json ``` [Registry JSON](https://pi-shadcn.vercel.app/r/loading-screen.json) ## Preview ```tsx import { LoadingScreen } from "@/components/loading-screen"; export function Preview() { return (
Revealed content
} text="Preparing workspace..." />
); } function DemoLogo() { return ( ); } ``` ## Source ### loading-screen.tsx ```tsx "use client"; import type { CSSProperties, ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface LoadingScreenProps { /** * Flip to `true` once the real work behind the screen has finished. The screen * still honors `minDuration` first, then completes the progress bar and fades * out — so a fast load never produces a jarring flash. */ ready: boolean; /** Brand mark rendered above the progress bar. */ logo?: ReactNode; /** Caption shown beneath the progress bar. */ text?: ReactNode; /** * Controlled progress, 0–100. Omit to let the screen auto-ease toward * completion, which is the common case when real progress isn't measurable. */ progress?: number; /** * Minimum time, in ms, to stay visible even if `ready` is already `true`. * @default 1200 */ minDuration?: number; /** * Fade-out transition duration, in ms. * @default 600 */ fadeDuration?: number; /** Fired once the fade-out has finished and the screen has fully left. */ onDone?: () => void; /** * Positioning mode for the overlay. * @default "fixed" */ position?: "fixed" | "absolute"; className?: string; } type Phase = "visible" | "leaving" | "gone"; /** * A full-screen branded loading overlay with a logo, progress bar and caption. * * It owns its own lifecycle: it stays up until `ready` is `true` *and* at least * `minDuration` has elapsed, then fades out and unmounts itself (rendering * `null`). To restart it for a new load — e.g. navigating between resources — * give it a changing `key` so React remounts it. */ export function LoadingScreen({ ready, logo, text, progress, minDuration = 1200, fadeDuration = 600, onDone, position = "fixed", className, }: LoadingScreenProps) { const [phase, setPhase] = useState("visible"); // Anchored once per mount; restart by remounting with a new `key`. const [startedAt] = useState(() => Date.now()); // Keep the latest callback without re-running the fade-out timer effect. const onDoneRef = useRef(onDone); onDoneRef.current = onDone; // Once data is ready, hold for the remaining dwell time, then start leaving. useEffect(() => { if (phase !== "visible" || !ready) return undefined; const remaining = Math.max(0, minDuration - (Date.now() - startedAt)); const timer = setTimeout(setPhase, remaining, "leaving"); return () => clearTimeout(timer); }, [phase, ready, minDuration, startedAt]); // Unmount only after the fade-out transition has finished. useEffect(() => { if (phase !== "leaving") return undefined; const timer = setTimeout(() => { setPhase("gone"); onDoneRef.current?.(); }, fadeDuration); return () => clearTimeout(timer); }, [phase, fadeDuration]); const isControlled = progress != null; const easedProgress = useEasedProgress(!isControlled && phase !== "gone", phase !== "visible"); const displayProgress = Math.min(100, Math.max(0, isControlled ? progress : easedProgress)); if (phase === "gone") return null; const isLeaving = phase === "leaving"; return (
{logo != null && (
{logo}
)}
{text != null &&

{text}

}
); } // Eases a progress value toward 92% while loading, then to 100% on completion, // so the bar feels like it's doing real work rather than ticking a fixed clock. function useEasedProgress(active: boolean, complete: boolean) { const [value, setValue] = useState(8); useEffect(() => { if (!active) return undefined; let running = true; let frame = 0; const tick = () => { let reached = false; setValue((prev) => { const target = complete ? 100 : 92; const next = prev + (target - prev) * 0.045; if (Math.abs(target - next) < 0.4) { reached = true; return target; } return next; }); if (running && !reached) frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => { running = false; cancelAnimationFrame(frame); }; }, [active, complete]); return value; } ``` ## Usage A full-screen branded loading overlay with progress and a minimum dwell time. ```tsx import { LoadingScreen } from "@/components/loading-screen"; ``` --- URL: https://pi-shadcn.vercel.app/blocks/offset-pagination.md An offset-based pagination bar with page-size controls and reusable state helpers. # Offset Pagination An offset-based pagination bar with page-size controls and reusable state helpers. ## Installation ```bash npx shadcn@latest add https://pi-shadcn.vercel.app/r/offset-pagination.json ``` [Registry JSON](https://pi-shadcn.vercel.app/r/offset-pagination.json) ## Preview ```tsx import { useState } from "react"; import { OffsetPaginationBar } from "@/components/offset-pagination/offset-pagination-bar"; import { getOffsetPaginationState } from "@/components/offset-pagination/utils"; export function Preview() { const [limit, setLimit] = useState(20); const [offset, setOffset] = useState(40); const pagination = getOffsetPaginationState({ limit, offset, total: 248 }); return ( setOffset(pagination.previousOffset)} onNext={() => setOffset(pagination.nextOffset)} onLimitChange={(nextLimit) => { setLimit(nextLimit); setOffset(0); }} /> ); } ``` ## Source ### offset-pagination.ts ```ts export { OffsetPaginationBar } from "@/components/offset-pagination/offset-pagination-bar"; export type { OffsetPaginationBarProps } from "@/components/offset-pagination/offset-pagination-bar"; export * from "@/components/offset-pagination/utils"; ``` ### offset-pagination-bar.tsx ```tsx "use client"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { OffsetPaginationLabels, OffsetPaginationState } from "@/components/offset-pagination/utils"; import { OFFSET_PAGINATION_DEFAULT_LABELS } from "@/components/offset-pagination/utils"; const DEFAULT_PAGE_SIZE_OPTIONS = [20, 50, 100]; export interface OffsetPaginationBarProps { pagination: OffsetPaginationState; onPrevious: () => void; onNext: () => void; onLimitChange: (limit: number) => void; pageSizeOptions?: number[]; /** Override any user-facing copy. Unspecified fields fall back to English. */ labels?: Partial; className?: string; } export function OffsetPaginationBar({ pagination, onPrevious, onNext, onLimitChange, pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, labels, className, }: OffsetPaginationBarProps) { const l = { ...OFFSET_PAGINATION_DEFAULT_LABELS, ...labels }; return (

{pagination.total.toLocaleString()} {l.results}

{l.page} {pagination.page} / {pagination.totalPages}
); } ``` ### offset-pagination-utils.ts ```ts export interface OffsetPaginationSearch { limit: number; offset: number; } export interface OffsetPaginationConfig { defaultLimit?: number; maxLimit?: number; defaultOffset?: number; } export interface OffsetPaginationState extends OffsetPaginationSearch { page: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; previousOffset: number; nextOffset: number; } /** * User-facing copy for `OffsetPaginationBar`. Every field has an English default * so the bar renders standalone; consumers override via `labels` (e.g. to * translate). The block stays i18n-agnostic and never imports a translation * library. */ export interface OffsetPaginationLabels { /** Trailing noun after the total count, e.g. "1,024 results". */ results: string; /** Lead-in before the page numbers, e.g. "Page 2 / 9". */ page: string; previous: string; next: string; /** Suffix in the page-size options, e.g. "20 / page". */ perPage: string; rowsPerPage: string; } export const OFFSET_PAGINATION_DEFAULT_LABELS: OffsetPaginationLabels = { results: "results", page: "Page", previous: "Previous", next: "Next", perPage: "/ page", rowsPerPage: "Rows per page", }; const DEFAULT_LIMIT = 20; const MAX_LIMIT = 100; const DEFAULT_OFFSET = 0; export function parsePositiveInt(value: unknown, defaultValue: number): number { const n = Number(value); if (!Number.isFinite(n) || n < 1) return defaultValue; return Math.floor(n); } export function parseNonNegativeInt(value: unknown, defaultValue: number): number { const n = Number(value); if (!Number.isFinite(n) || n < 0) return defaultValue; return Math.floor(n); } export function parseOffsetPaginationSearch( search: Record, config: OffsetPaginationConfig = {}, ): OffsetPaginationSearch { const defaultLimit = config.defaultLimit ?? DEFAULT_LIMIT; const maxLimit = config.maxLimit ?? MAX_LIMIT; const defaultOffset = config.defaultOffset ?? DEFAULT_OFFSET; return { limit: Math.min(parsePositiveInt(search.limit, defaultLimit), maxLimit), offset: parseNonNegativeInt(search.offset, defaultOffset), }; } export function createOffsetPaginationSearch( limit = DEFAULT_LIMIT, offset = DEFAULT_OFFSET, ): OffsetPaginationSearch { return { limit, offset }; } export function updateOffsetPaginationSearch( prev: T, next: Partial, options: { resetOffset?: boolean } = {}, ): T { const merged = { ...prev, ...next }; return { ...merged, offset: options.resetOffset === false ? (next.offset ?? prev.offset) : DEFAULT_OFFSET, }; } export function getOffsetPaginationState(input: { limit: number; offset: number; total?: number; hasNextPage?: boolean; }): OffsetPaginationState { const total = Math.max(0, input.total ?? 0); const totalPages = Math.max(1, Math.ceil(total / input.limit)); const page = Math.min(Math.floor(input.offset / input.limit) + 1, totalPages); const hasPreviousPage = input.offset > 0; const hasNextPage = input.hasNextPage ?? page < totalPages; return { limit: input.limit, offset: input.offset, page, total, totalPages, hasNextPage, hasPreviousPage, previousOffset: Math.max(0, input.offset - input.limit), nextOffset: input.offset + input.limit, }; } ``` ## Usage An offset-based pagination bar with page-size controls and reusable state helpers. ```tsx import { OffsetPaginationBar, getOffsetPaginationState } from "@/components/offset-pagination"; ``` --- URL: https://pi-shadcn.vercel.app/blocks/stats-panel.md A simple multi-file block with a component and local data helper. # Stats Panel A simple multi-file block with a component and local data helper. ## Installation ```bash npx shadcn@latest add https://pi-shadcn.vercel.app/r/stats-panel.json ``` [Registry JSON](https://pi-shadcn.vercel.app/r/stats-panel.json) ## Preview ```tsx import { StatsPanel } from "@/components/stats-panel"; export function Preview() { return ; } ``` ## Source ### components/stats-panel.tsx ```tsx import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ExampleCard } from "@/components/ui/example-card"; import { stats } from "@/lib/stats-data"; function StatsPanel() { return (
Registry health {stats.map((stat) => (
{stat.label} {stat.detail}
{stat.value}
))}
); } export { StatsPanel }; ``` ### lib/stats-data.ts ```ts type Stat = { label: string; value: string; detail: string; }; const stats: Stat[] = [ { label: "Components", value: "12", detail: "Ready to publish", }, { label: "Blocks", value: "3", detail: "Documented examples", }, { label: "Schema", value: "100%", detail: "Validated output", }, ]; export { stats, type Stat }; ``` ## Usage Use the panel as a dashboard summary block. It installs the component and the local data helper together, plus its local `example-card` dependency. ```tsx import { StatsPanel } from "@/components/stats-panel"; ``` --- URL: https://pi-shadcn.vercel.app/blocks/workbench-app-shell.md A resizable workbench application shell with sidebar, topbar, split view, right panel, and settings primitives. # Workbench App Shell A resizable workbench application shell with sidebar, topbar, split view, right panel, and settings primitives. ## Installation ```bash npx shadcn@latest add https://pi-shadcn.vercel.app/r/workbench-app-shell.json ``` [Registry JSON](https://pi-shadcn.vercel.app/r/workbench-app-shell.json) ## Preview ```tsx import { WorkbenchAppShellDemo } from "@/stories/registry/workbench-app-shell-demo"; export function Preview() { return (
); } ``` ## Source ### workbench-app-shell.tsx ```tsx "use client"; export { AppShellLayout } from "@/components/workbench-app-shell/app-shell-layout"; export type { AppShellLayoutProps } from "@/components/workbench-app-shell/app-shell-layout"; export { AppShellRoot } from "@/components/workbench-app-shell/app-shell-root"; export type { AppShellRootProps } from "@/components/workbench-app-shell/app-shell-root"; export { CommandHint, CommandHintText } from "@/components/workbench-app-shell/command-hint"; export { ContentBody, ContentHeader, MainContent, ShellContentScroll } from "@/components/workbench-app-shell/main-content"; export { RightPanel, RightPanelBody, RightPanelCloseButton, RightPanelHeader } from "@/components/workbench-app-shell/right-panel"; export { SettingsContent, SettingsNav, SettingsNavGroup, SettingsNavGroupTitle, SettingsNavItem, SettingsSection, SettingsSectionDescription, SettingsSectionTitle, SettingsShell, } from "@/components/workbench-app-shell/settings-shell"; export type { SettingsNavItemProps } from "@/components/workbench-app-shell/settings-shell"; export { ShellNavModelView } from "@/components/workbench-app-shell/shell-nav-model"; export type { ShellNavModelViewProps } from "@/components/workbench-app-shell/shell-nav-model"; export { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarHeaderBack, SidebarItem, SidebarItemBadge, SidebarItemIcon, SidebarItemLabel, SidebarSection, SidebarSectionTitle, } from "@/components/workbench-app-shell/sidebar"; export type { SidebarHeaderBackProps, SidebarItemProps } from "@/components/workbench-app-shell/sidebar"; export { SplitView } from "@/components/workbench-app-shell/split-view"; export type { SplitViewProps } from "@/components/workbench-app-shell/split-view"; export { Topbar, TopbarActions, TopbarBreadcrumbs, TopbarRightPanelToggle, TopbarSidebarToggle, TopbarTitle, TopbarViewTabs, } from "@/components/workbench-app-shell/topbar"; export type { ShellNavItem, ShellNavModel, ShellNavSection } from "@/components/workbench-app-shell/types"; export { WorkbenchRoot, useWorkbenchContext } from "@/components/workbench-app-shell/workbench-context"; export type { WorkbenchContextValue, WorkbenchRootProps } from "@/components/workbench-app-shell/workbench-context"; ``` ### app-shell-layout.tsx ```tsx "use client"; import * as React from "react"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; import { SplitView } from "@/components/workbench-app-shell/split-view"; import { useWorkbenchContext } from "@/components/workbench-app-shell/workbench-context"; export interface AppShellLayoutProps { sidebar?: React.ReactNode; topbar?: React.ReactNode; children?: React.ReactNode; rightPanel?: React.ReactNode; appearance?: "default" | "island"; className?: string; contentClassName?: string; rightPanelWidth?: number; } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } export function AppShellLayout({ sidebar, topbar, children, rightPanel, appearance = "default", className, contentClassName, rightPanelWidth = 320, }: AppShellLayoutProps) { const { sidebarCollapsed, sidebarSize, setSidebarSize, sidebarMinSize, sidebarMaxSize, sidebarMobileOpen, setSidebarMobileOpen, rightPanelMobileOpen, setRightPanelMobileOpen, } = useWorkbenchContext(); const handleSidebarResizeStart = React.useCallback( (event: React.PointerEvent) => { event.preventDefault(); const startX = event.clientX; const startSize = sidebarSize; const handlePointerMove = (moveEvent: PointerEvent) => { const delta = moveEvent.clientX - startX; setSidebarSize(clamp(startSize + delta, sidebarMinSize, sidebarMaxSize)); }; const stopResize = () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", stopResize); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", stopResize); }, [setSidebarSize, sidebarMaxSize, sidebarMinSize, sidebarSize], ); const isIsland = appearance === "island"; return (
{sidebar && !sidebarCollapsed && ( <>
{ if (event.key === "ArrowLeft") { setSidebarSize((size) => clamp(size - 12, sidebarMinSize, sidebarMaxSize)); } if (event.key === "ArrowRight") { setSidebarSize((size) => clamp(size + 12, sidebarMinSize, sidebarMaxSize)); } }} className="group relative hidden w-px cursor-col-resize items-stretch justify-center py-4 md:flex" >
)}
{topbar} {children}
{sidebar && (
{sidebar}
)} {rightPanel && (
{rightPanel}
)}
); } ``` ### app-shell-root.tsx ```tsx "use client"; import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; import type { WorkbenchRootProps } from "@/components/workbench-app-shell/workbench-context"; import { WorkbenchRoot } from "@/components/workbench-app-shell/workbench-context"; const shellRootVariants = cva("h-dvh w-full bg-background text-foreground", { variants: { density: { compact: "text-sm", comfortable: "text-sm", spacious: "text-base", }, shape: { workbench: "", linear: "[--shell-border-radius:--spacing(2)] [--shell-gap:--spacing(2)]", notion: "[--shell-border-radius:--spacing(1)] [--shell-gap:--spacing(1)]", }, }, defaultVariants: { density: "comfortable", shape: "workbench", }, }); export interface AppShellRootProps extends WorkbenchRootProps { className?: string; density?: "compact" | "comfortable" | "spacious"; shape?: "workbench" | "linear" | "notion"; } export function AppShellRoot({ children, className, density, shape, ...props }: AppShellRootProps) { return (
{children}
); } ``` ### command-hint.tsx ```tsx "use client"; import type * as React from "react"; import { cn } from "@/lib/utils"; export function CommandHint({ className, ...props }: React.ComponentProps<"div">) { return (
); } export function CommandHintText({ className, ...props }: React.ComponentProps<"span">) { return ; } ``` ### main-content.tsx ```tsx "use client"; import type * as React from "react"; import { cn } from "@/lib/utils"; import { OverlayScrollContainer } from "@/components/overlay-scroll-container"; export function MainContent({ className, ...props }: React.ComponentProps<"section">) { return
; } export function ContentHeader({ className, ...props }: React.ComponentProps<"header">) { return
; } export function ShellContentScroll({ className, ...props }: React.ComponentPropsWithoutRef<"div">) { return ; } export function ContentBody({ className, ...props }: React.ComponentPropsWithoutRef<"div">) { return ; } ``` ### right-panel.tsx ```tsx "use client"; import { XIcon } from "lucide-react"; import type * as React from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { OverlayScrollContainer } from "@/components/overlay-scroll-container"; import { useWorkbenchContext } from "@/components/workbench-app-shell/workbench-context"; export function RightPanel({ className, ...props }: React.ComponentProps<"aside">) { return