Color themes
Pass a{" "}
diff --git a/app/docs/components/code-block/page.tsx b/app/docs/components/code-block/page.tsx
index c2bffe2..b201567 100644
--- a/app/docs/components/code-block/page.tsx
+++ b/app/docs/components/code-block/page.tsx
@@ -90,7 +90,7 @@ export default async function CodeBlockPage() {
}
>
{/* Examples */}
-
+
Examples
@@ -213,7 +213,7 @@ export default async function CodeBlockPage() {
{/* Color themes */}
-
+
Color themes
Pass a{" "}
diff --git a/app/docs/components/code-line/page.tsx b/app/docs/components/code-line/page.tsx
index d490806..e151ad2 100644
--- a/app/docs/components/code-line/page.tsx
+++ b/app/docs/components/code-line/page.tsx
@@ -66,7 +66,7 @@ export default async function CodeLinePage() {
}
>
{/* Examples */}
-
+
Examples
@@ -173,7 +173,7 @@ export default async function CodeLinePage() {
{/* Color themes */}
-
+
Color themes
Pass a{" "}
diff --git a/app/docs/components/cron-schedule/page.tsx b/app/docs/components/cron-schedule/page.tsx
index c71b3bd..30cc10e 100644
--- a/app/docs/components/cron-schedule/page.tsx
+++ b/app/docs/components/cron-schedule/page.tsx
@@ -70,7 +70,7 @@ export default function CronSchedulePage() {
}
>
{/* Examples */}
-
+
Examples
diff --git a/app/docs/components/diff-viewer/page.tsx b/app/docs/components/diff-viewer/page.tsx
new file mode 100644
index 0000000..1ca3a72
--- /dev/null
+++ b/app/docs/components/diff-viewer/page.tsx
@@ -0,0 +1,260 @@
+import type { Metadata } from "next"
+import { DiffViewer } from "@/registry/diff-viewer/diff-viewer"
+import { ApiRefTable } from "@/registry/api-ref-table/api-ref-table"
+import { ComponentDocsPage } from "@/components/docs/component-docs-page"
+import { VariantGrid } from "@/components/docs/variant-grid"
+import { CodeLine } from "@/registry/code-line/code-line"
+
+export const metadata: Metadata = {
+ title: "Diff Viewer",
+ description:
+ "Code diff viewer with line numbers and add/remove coloring. Supports unified and split layouts.",
+}
+
+const sourceFiles = [
+ "registry/diff-viewer/diff-viewer.tsx",
+ "registry/diff-viewer/diff-viewer-client.tsx",
+]
+
+const oldCode = `import { useState } from "react"
+
+function Counter() {
+ const [count, setCount] = useState(0)
+
+ return (
+ setCount(count + 1)}>
+ Count: {count}
+
+ )
+}`
+
+const newCode = `import { useState, useCallback } from "react"
+
+function Counter({ initial = 0 }) {
+ const [count, setCount] = useState(initial)
+
+ const increment = useCallback(() => {
+ setCount((prev) => prev + 1)
+ }, [])
+
+ return (
+
+ Count: {count}
+
+ )
+}`
+
+const configOld = `{
+ "compilerOptions": {
+ "target": "es5",
+ "module": "commonjs",
+ "strict": true
+ }
+}`
+
+const configNew = `{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true
+ }
+}`
+
+export default function DiffViewerPage() {
+ return (
+
+
+
+ }
+ usage={
+ <>
+
+ `}
+ />
+ >
+ }
+ >
+ {/* Layouts */}
+
+ Layouts
+
+ ),
+ code: ` `,
+ },
+ {
+ label: "Split",
+ preview: (
+
+ ),
+ code: ` `,
+ },
+ ]}
+ files={sourceFiles}
+ columns={1}
+ fullWidth
+ registryName="diff-viewer"
+ />
+
+
+ {/* Examples */}
+
+ Examples
+
+ ),
+ code: ` `,
+ },
+ {
+ label: "No header",
+ preview: (
+
+ ),
+ code: ` `,
+ },
+ ]}
+ files={sourceFiles}
+ columns={1}
+ fullWidth
+ registryName="diff-viewer"
+ />
+
+
+ {/* API Reference */}
+
+
+ {/* Notes */}
+
+ Notes
+
+
+ Two input modes. {" "}
+ Pass{" "}
+
+ oldCode
+ {" "}
+ +{" "}
+
+ newCode
+ {" "}
+ to compute the diff, or pass a{" "}
+
+ patch
+ {" "}
+ string if you already have one.
+
+
+ Icon library. {" "}
+ Uses{" "}
+
+ Lucide
+ {" "}
+ icons by default. Since this is copy-paste code, you can swap the
+ imports if your project uses a different icon library.
+
+
+
+
+ )
+}
diff --git a/app/docs/components/env-table/page.tsx b/app/docs/components/env-table/page.tsx
index fd96e08..31aaad3 100644
--- a/app/docs/components/env-table/page.tsx
+++ b/app/docs/components/env-table/page.tsx
@@ -154,7 +154,7 @@ export default function EnvTablePage() {
}
>
{/* Examples */}
-
+
Examples
diff --git a/app/docs/components/file-tree/page.tsx b/app/docs/components/file-tree/page.tsx
index 44512d0..826b794 100644
--- a/app/docs/components/file-tree/page.tsx
+++ b/app/docs/components/file-tree/page.tsx
@@ -169,7 +169,7 @@ export default function FileTreePage() {
}
>
{/* Examples */}
-
+
Examples
diff --git a/app/docs/components/github-button-group/page.tsx b/app/docs/components/github-button-group/page.tsx
index 7482c94..3c4fb09 100644
--- a/app/docs/components/github-button-group/page.tsx
+++ b/app/docs/components/github-button-group/page.tsx
@@ -63,7 +63,7 @@ export default async function GitHubButtonGroupPage() {
)}
{/* Examples */}
-
+
Examples
diff --git a/app/docs/components/github-stars-button/page.tsx b/app/docs/components/github-stars-button/page.tsx
index defe7fe..48e4721 100644
--- a/app/docs/components/github-stars-button/page.tsx
+++ b/app/docs/components/github-stars-button/page.tsx
@@ -56,7 +56,7 @@ export default async function GitHubStarsButtonPage() {
{/* Examples */}
-
+
Examples
diff --git a/app/docs/components/json-viewer/page.tsx b/app/docs/components/json-viewer/page.tsx
index 3d1bfc8..ef42dbf 100644
--- a/app/docs/components/json-viewer/page.tsx
+++ b/app/docs/components/json-viewer/page.tsx
@@ -147,7 +147,7 @@ export default function JsonViewerPage() {
}
>
{/* Examples */}
-
+
Examples
@@ -298,7 +298,7 @@ export default function JsonViewerPage() {
{/* Color themes */}
-
+
Color themes
Pass a{" "}
diff --git a/app/docs/components/kbd/page.tsx b/app/docs/components/kbd/page.tsx
new file mode 100644
index 0000000..aea1f67
--- /dev/null
+++ b/app/docs/components/kbd/page.tsx
@@ -0,0 +1,357 @@
+import type { Metadata } from "next"
+import { Kbd, KbdCombo, builtInSchemes, type BuiltInColorScheme } from "@/registry/kbd/kbd"
+import { ApiRefTable } from "@/registry/api-ref-table/api-ref-table"
+import { ComponentDocsPage } from "@/components/docs/component-docs-page"
+import { VariantGrid } from "@/components/docs/variant-grid"
+import { CodeLine } from "@/registry/code-line/code-line"
+import { KbdPlayground } from "./playground"
+
+export const metadata: Metadata = {
+ title: "Kbd",
+ description:
+ "Keyboard shortcut key rendered as a styled keycap. Three visual profiles: flat, raised, and sculpted.",
+}
+
+const sourceFiles = ["registry/kbd/kbd.tsx"]
+
+export default function KbdPage() {
+ return (
+
+
+
+
+
+ }
+ usage={
+ <>
+
+ ⌘`} />
+ `}
+ />
+ >
+ }
+ >
+ {/* Playground */}
+
+
+ {/* Variants */}
+
+ ),
+ code: `Esc `,
+ },
+ {
+ label: "Raised",
+ preview: (
+
+ Esc
+
+
+
+ ),
+ code: `Esc `,
+ },
+ {
+ label: "Sculpted",
+ preview: (
+
+ Esc
+
+
+
+ ),
+ code: `Esc `,
+ },
+ ]}
+ files={sourceFiles}
+ columns={1}
+ registryName="kbd"
+ />
+
+
+ {/* Sizes */}
+
+ Sizes
+
+
+
+ Open command palette
+
+
+ ),
+ code: `⌘ `,
+ },
+ {
+ label: "Medium",
+ preview: (
+
+
+
+ Open command palette
+
+
+ ),
+ code: `⌘ `,
+ },
+ {
+ label: "Large",
+ preview: (
+
+
+
+ Open command palette
+
+
+ ),
+ code: `⌘ `,
+ },
+ ]}
+ files={sourceFiles}
+ columns={3}
+ registryName="kbd"
+ />
+
+
+ {/* Examples */}
+
+ Examples
+
+ ),
+ code: ` `,
+ },
+ {
+ label: "Arrow keys",
+ preview: (
+
+ ←
+ ↑
+ ↓
+ →
+
+ ),
+ code: `← `,
+ },
+ {
+ label: "Inline with text",
+ preview: (
+
+ Press{" "}
+ {" "}
+ to open the command palette
+
+ ),
+ code: `Press to open the command palette
`,
+ },
+ ]}
+ files={sourceFiles}
+ columns={3}
+ registryName="kbd"
+ />
+
+
+ {/* Color Schemes */}
+
+ Color Schemes
+
+ Built-in color palettes inspired by popular keycap sets. Pass a name
+ string or a custom{" "}
+
+ {"{ bg, text, border }"}
+ {" "}
+ object.
+
+
+ {(Object.keys(builtInSchemes) as BuiltInColorScheme[]).map((name) => (
+
+
+ ⌘
+ K
+
+
+ {name}
+
+
+ ))}
+
+
+ ),
+ code: ` `,
+ },
+ {
+ label: "Olivia",
+ preview: (
+
+ ),
+ code: ` `,
+ },
+ {
+ label: "Botanical",
+ preview: (
+
+ ),
+ code: ` `,
+ },
+ {
+ label: "Laser",
+ preview: (
+
+ ),
+ code: ` `,
+ },
+ {
+ label: "Custom colors",
+ preview: (
+
+ ),
+ code: ` `,
+ },
+ ]}
+ files={sourceFiles}
+ columns={3}
+ registryName="kbd"
+ />
+
+
+ {/* API Reference */}
+
+
+ )
+}
diff --git a/app/docs/components/kbd/playground.tsx b/app/docs/components/kbd/playground.tsx
new file mode 100644
index 0000000..6f04e91
--- /dev/null
+++ b/app/docs/components/kbd/playground.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import { KbdCombo, type BuiltInColorScheme } from "@/registry/kbd/kbd"
+import {
+ ComponentPlayground,
+ type PlaygroundControl,
+} from "@/components/docs/component-playground"
+
+const colorSchemeOptions = [
+ "none",
+ "dolch",
+ "olivia",
+ "botanical",
+ "oblivion",
+ "8008",
+ "laser",
+ "mizu",
+ "dracula",
+ "hammerhead",
+ "wob",
+ "bow",
+ "cream",
+]
+
+const controls: PlaygroundControl[] = [
+ {
+ name: "variant",
+ type: "select",
+ options: ["flat", "raised", "sculpted"],
+ default: "raised",
+ },
+ {
+ name: "size",
+ type: "select",
+ options: ["sm", "md", "lg"],
+ default: "md",
+ },
+ {
+ name: "colorScheme",
+ type: "select",
+ label: "colorScheme",
+ options: colorSchemeOptions,
+ default: "none",
+ },
+ {
+ name: "keys",
+ type: "preset",
+ label: "Keys",
+ presets: [
+ { label: "⌘ K", value: ["⌘", "K"], code: '["⌘", "K"]' },
+ { label: "Ctrl Shift P", value: ["Ctrl", "Shift", "P"], code: '["Ctrl", "Shift", "P"]' },
+ { label: "⌥ ↑", value: ["⌥", "↑"], code: '["⌥", "↑"]' },
+ { label: "← ↑ ↓ →", value: ["←", "↑", "↓", "→"], code: '["←", "↑", "↓", "→"]' },
+ { label: "Esc", value: ["Esc"], code: '["Esc"]' },
+ ],
+ default: "⌘ K",
+ },
+]
+
+export function KbdPlayground() {
+ return (
+ {
+ const variant = (props.variant as string) ?? "raised"
+ const size = (props.size as string) ?? "md"
+ const colorScheme = props.colorScheme === "none" ? undefined : (props.colorScheme as BuiltInColorScheme)
+ const keys = (props.keys as string[]) ?? ["⌘", "K"]
+
+ return (
+
+ )
+ }}
+ />
+ )
+}
diff --git a/app/docs/components/log-viewer/page.tsx b/app/docs/components/log-viewer/page.tsx
index 214c924..5a002da 100644
--- a/app/docs/components/log-viewer/page.tsx
+++ b/app/docs/components/log-viewer/page.tsx
@@ -130,7 +130,7 @@ export default function LogViewerPage() {
{/* Variants */}
-
+
Variants
@@ -240,7 +240,7 @@ export default function LogViewerPage() {
{/* Custom colors */}
-
+
Custom colors
Pass a colorScale to
diff --git a/app/docs/components/npm-badge/page.tsx b/app/docs/components/npm-badge/page.tsx
index df91833..93fda61 100644
--- a/app/docs/components/npm-badge/page.tsx
+++ b/app/docs/components/npm-badge/page.tsx
@@ -45,7 +45,7 @@ export default async function NpmBadgePage() {
}
>
{/* Layouts */}
-
+
Layouts
@@ -160,7 +160,7 @@ export default async function NpmBadgePage() {
{/* Variants (inline) */}
-
+
Examples
diff --git a/app/docs/components/producthunt-button/page.tsx b/app/docs/components/producthunt-button/page.tsx
index c30559f..529c35f 100644
--- a/app/docs/components/producthunt-button/page.tsx
+++ b/app/docs/components/producthunt-button/page.tsx
@@ -5,6 +5,7 @@ import { ComponentDocsPage } from "@/components/docs/component-docs-page"
import { VariantGrid } from "@/components/docs/variant-grid"
import { ProductHuntButtonPlayground } from "./playground"
import { CodeLine } from "@/registry/code-line/code-line"
+import { Stepper, StepperItem } from "@/registry/stepper/stepper"
export const metadata: Metadata = {
title: "Product Hunt Button",
@@ -78,7 +79,7 @@ export default async function ProductHuntButtonPage() {
{/* Examples */}
-
+
Examples
@@ -431,33 +432,49 @@ export default async function ProductHuntButtonPage() {
The component fetches live data from the Product Hunt GraphQL API.
To enable this, you need a developer token.
-
-
- Go to the{" "}
-
- Product Hunt API Dashboard
-
- .
-
-
- Click Add an Application .
- Enter any name and redirect URI (e.g. your site URL).
-
-
- After creating the app, scroll down to the{" "}
- Developer Token section.
- Copy the token value.
-
-
- Add it to your environment:
-
-
-
+
+
+
+ Go to the{" "}
+
+ Product Hunt API Dashboard
+
+ .
+
+
+
+
+ Click Add an Application .
+ Enter any name and redirect URI (e.g. your site URL).
+
+
+
+
+ After creating the app, scroll down to the{" "}
+ Developer Token {" "}
+ section. Copy the token value.
+
+
+
+
+
+
The developer token never expires and requires no OAuth flow. No
API key or secret exchange needed — the token from the dashboard
diff --git a/app/docs/components/request-viewer/page.tsx b/app/docs/components/request-viewer/page.tsx
index 499ce7d..1d17a57 100644
--- a/app/docs/components/request-viewer/page.tsx
+++ b/app/docs/components/request-viewer/page.tsx
@@ -250,7 +250,7 @@ export default async function RequestViewerPage() {
{/* Examples */}
-
+
Examples
diff --git a/app/docs/components/stepper/page.tsx b/app/docs/components/stepper/page.tsx
index bc27fee..b1f0d36 100644
--- a/app/docs/components/stepper/page.tsx
+++ b/app/docs/components/stepper/page.tsx
@@ -67,7 +67,7 @@ export default function StepperPage() {
}
>
{/* Examples */}
-
+
Examples
diff --git a/app/docs/components/tip-jar/page.tsx b/app/docs/components/tip-jar/page.tsx
index 8f3e164..1f319d6 100644
--- a/app/docs/components/tip-jar/page.tsx
+++ b/app/docs/components/tip-jar/page.tsx
@@ -87,7 +87,7 @@ export default async function TipJarPage() {
}
>
{/* Examples */}
-
+
Examples
diff --git a/app/docs/installation/page.tsx b/app/docs/installation/page.tsx
index 52964fd..b19cac7 100644
--- a/app/docs/installation/page.tsx
+++ b/app/docs/installation/page.tsx
@@ -1,5 +1,7 @@
import type { Metadata } from "next"
+import { CodeBlock } from "@/components/docs/code-block"
import { CodeLine } from "@/registry/code-line/code-line"
+import { Stepper, StepperItem } from "@/registry/stepper/stepper"
import { CopyPromptButton } from "@/components/docs/copy-prompt-button"
import { generateInstallationPrompt } from "@/lib/prompts"
@@ -34,97 +36,180 @@ export default async function InstallationPage() {
- Prerequisites
+
+ With shadcn CLI
+
- Make sure you have a project set up with:
+ The fastest way to get started. The CLI handles source files,
+ npm dependencies, and registry dependencies in one command.
-
+
+
+
+
+
+
+
+
+
+ This adds the jalco registry to your{" "}
+
+ components.json
+
+ :
+
+
+
+
+
+
+
+
+
This will:
+
+ Download the component source into your project
+ Install any required npm dependencies
+
+ Add any shadcn registry dependencies (like{" "}
+
+ button
+
+ ,{" "}
+
+ card
+
+ , etc.)
+
+
+
+
+
+
+
+
+
- Install a component
+ Manual setup
- Use the{" "}
- shadcn{" "}
- CLI to add any component from the registry:
+ If you prefer to skip the{" "}
+
+ registry add
+ {" "}
+ command, add the registry entry to your{" "}
+
+ components.json
+ {" "}
+ directly:
+
+
+
+ Then install components with the CLI as usual:
- This will:
-
- Download the component source into your project
- Install any required npm dependencies
-
- Add any shadcn registry dependencies (like{" "}
- button
- ,{" "}
- card
- , etc.)
-
-
- Manual installation
+ Without shadcn
- If you prefer not to use the CLI, you can copy the component source
- directly from the docs pages. Each component page includes the full
- source code.
+ Don't use shadcn? No problem. Every component page includes the
+ full source code — copy it directly into your project and install any
+ listed dependencies manually. The components are plain React +
+ Tailwind CSS with no framework lock-in.
- Registry
-
- jalco ui is listed in the official shadcn registry. All components use
- the{" "}
- @jalco{" "}
- namespace:
-
-
+
+ MCP integration
+
- You can also install directly from the registry URL:
+ Initialize the shadcn MCP server to give your AI editor full context
+ of the jalco ui library:
diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx
index 935a05c..4948c37 100644
--- a/app/docs/layout.tsx
+++ b/app/docs/layout.tsx
@@ -33,7 +33,7 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
jal-co/ui
@@ -42,19 +42,19 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
Components
Changelog
Installation
diff --git a/app/docs/page.tsx b/app/docs/page.tsx
index eed8d1d..9675e88 100644
--- a/app/docs/page.tsx
+++ b/app/docs/page.tsx
@@ -4,7 +4,7 @@ import { join } from "node:path"
import Link from "next/link"
import { docsNav, getActiveBadge } from "@/lib/docs"
import { getRegistryItem } from "@/lib/registry"
-import { ComponentCardPreviewImage } from "@/components/docs/component-card-preview-image"
+import { ThemeImage } from "@/components/docs/theme-image"
export const metadata: Metadata = {
title: "Components — jal-co/ui",
@@ -77,11 +77,11 @@ export default function DocsPage() {
{hasImage && (
-
@@ -94,7 +94,7 @@ export default function DocsPage() {
{item.title}
{badge && (
-
+
{badge}
)}
diff --git a/app/globals.css b/app/globals.css
index 2aa5f87..00ec89f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -40,9 +40,9 @@
--font-sans: var(--font-public-sans);
--font-mono: var(--font-geist-mono);
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
+ --radius-sm: calc(var(--radius) - 2px);
+ --radius-md: var(--radius);
+ --radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px);
--font-heading: var(--font-geist);
@@ -72,7 +72,7 @@
--destructive: oklch(0.6300 0.1900 23.0300);
--border: oklch(0.9200 0 0);
--input: oklch(0.9400 0 0);
- --ring: oklch(0 0 0);
+ --ring: oklch(0.67 0.22 37.41);
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5500 0.2200 264.5300);
--chart-3: oklch(0.7200 0 0);
@@ -86,7 +86,7 @@
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.9400 0 0);
--sidebar-ring: oklch(0 0 0);
- --radius: 0.625rem;
+ --radius: 0.375rem;
}
.dark {
@@ -107,7 +107,7 @@
--destructive: oklch(0.6900 0.2000 23.9100);
--border: oklch(0.2600 0 0);
--input: oklch(0.3200 0 0);
- --ring: oklch(0.7200 0 0);
+ --ring: oklch(0.72 0.19 37.41);
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5800 0.2100 260.8400);
--chart-3: oklch(0.5600 0 0);
@@ -121,7 +121,7 @@
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.3200 0 0);
--sidebar-ring: oklch(0.7200 0 0);
- --radius: 0.625rem;
+ --radius: 0.375rem;
}
@layer base {
@@ -191,6 +191,15 @@
}
}
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
@layer components {
.code-block pre {
background: transparent !important;
diff --git a/components/docs/code-block-command.tsx b/components/docs/code-block-command.tsx
index da4c8d5..3f07f8b 100644
--- a/components/docs/code-block-command.tsx
+++ b/components/docs/code-block-command.tsx
@@ -137,7 +137,7 @@ export function CodeBlockCommand({
aria-selected={active === manager}
onClick={() => handleSelect(manager)}
className={cn(
- "flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
+ "flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
active === manager
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -151,7 +151,7 @@ export function CodeBlockCommand({
{copied ? (
diff --git a/components/docs/code-block-wrapper.tsx b/components/docs/code-block-wrapper.tsx
index 88072b3..593a147 100644
--- a/components/docs/code-block-wrapper.tsx
+++ b/components/docs/code-block-wrapper.tsx
@@ -54,7 +54,7 @@ export function CodeBlockWrapper({
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
- "flex w-full items-center justify-center gap-1.5 border-t py-2 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
+ "flex w-full items-center justify-center gap-1.5 border-t py-2 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50",
muted
? "border-border/40 bg-muted/10 hover:bg-muted/30"
: "border-border/60 bg-muted/30 hover:bg-muted/50"
diff --git a/components/docs/code-block.tsx b/components/docs/code-block.tsx
index 508f46b..8127331 100644
--- a/components/docs/code-block.tsx
+++ b/components/docs/code-block.tsx
@@ -12,6 +12,8 @@ interface CodeBlockProps {
overflow?: "default" | "scrollable" | "collapsible"
maxHeight?: number
muted?: boolean
+ /** Hide the header bar. Copy button floats inside the code area. */
+ compact?: boolean
className?: string
}
@@ -22,6 +24,7 @@ export async function CodeBlock({
overflow = "default",
maxHeight,
muted = false,
+ compact = false,
className,
}: CodeBlockProps) {
const highlighted = await highlightCode(code, language)
@@ -36,47 +39,54 @@ export async function CodeBlock({
className
)}
>
-
-
-
- {title ? (
-
- {title}
-
- ) : (
-
- {language}
-
+ {!compact && (
+
+
+
+ {title ? (
+
+ {title}
+
+ ) : (
+
+ {language}
+
+ )}
+
+
-
-
+ )}
+ {compact && (
+
+
+
+ )}
setMounted(true), [])
-
- // Avoid hydration mismatch — render nothing until mounted
- if (!mounted) {
- return
- }
-
- const mode = resolvedTheme === "light" ? "light" : "dark"
-
- return (
-
- )
-}
diff --git a/components/docs/component-docs-page.tsx b/components/docs/component-docs-page.tsx
index 7c77921..dc86fd3 100644
--- a/components/docs/component-docs-page.tsx
+++ b/components/docs/component-docs-page.tsx
@@ -107,9 +107,9 @@ export async function ComponentDocsPage({
{/* Requirements */}
{requirements && (
-
+
-
+
Requirements
{requirements}
diff --git a/components/docs/component-preview-tabs.tsx b/components/docs/component-preview-tabs.tsx
index 93a1d32..2803ce0 100644
--- a/components/docs/component-preview-tabs.tsx
+++ b/components/docs/component-preview-tabs.tsx
@@ -39,7 +39,7 @@ export function ComponentPreviewTabs({
aria-selected={active === "preview"}
onClick={() => setActive("preview")}
className={cn(
- "px-4 py-2.5 text-sm font-medium transition-colors",
+ "px-4 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
active === "preview"
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -53,7 +53,7 @@ export function ComponentPreviewTabs({
aria-selected={active === "code"}
onClick={() => setActive("code")}
className={cn(
- "px-4 py-2.5 text-sm font-medium transition-colors",
+ "px-4 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
active === "code"
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -85,7 +85,7 @@ export function ComponentPreviewTabs({
aria-selected={activeFile === i}
onClick={() => setActiveFile(i)}
className={cn(
- "rounded-t-md px-3 py-2 font-mono text-xs transition-colors",
+ "rounded-t-md px-3 py-2 font-mono text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
activeFile === i
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
diff --git a/components/docs/mobile-nav.tsx b/components/docs/mobile-nav.tsx
index 8b3c87e..80989de 100644
--- a/components/docs/mobile-nav.tsx
+++ b/components/docs/mobile-nav.tsx
@@ -29,7 +29,7 @@ export function MobileNav() {
setOpen(!open)}
- className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground md:hidden"
+ className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 md:hidden"
aria-label={open ? "Close navigation" : "Open navigation"}
>
{open ? : }
@@ -54,7 +54,7 @@ export function MobileNav() {
key={item.href}
href={item.href}
className={cn(
- "flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
+ "flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
pathname === item.href
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground"
@@ -62,7 +62,7 @@ export function MobileNav() {
>
{item.title}
{getActiveBadge(item) && (
-
+
{getActiveBadge(item)}
)}
diff --git a/components/docs/prev-next-nav.tsx b/components/docs/prev-next-nav.tsx
index 6ee46ba..264bfe7 100644
--- a/components/docs/prev-next-nav.tsx
+++ b/components/docs/prev-next-nav.tsx
@@ -24,11 +24,11 @@ export function PrevNextNav() {
if (!prev && !next) return null
return (
-
+
{prev ? (
@@ -42,7 +42,7 @@ export function PrevNextNav() {
{next ? (
Next
diff --git a/components/docs/previews/diff-viewer.tsx b/components/docs/previews/diff-viewer.tsx
new file mode 100644
index 0000000..c2918ef
--- /dev/null
+++ b/components/docs/previews/diff-viewer.tsx
@@ -0,0 +1,22 @@
+import { DiffViewer } from "@/registry/diff-viewer/diff-viewer"
+
+const oldCode = `function greet(name) {
+ return "Hello, " + name
+}`
+
+const newCode = `function greet(name: string) {
+ return \`Hello, \${name}!\`
+}`
+
+export default async function DiffViewerPreview() {
+ return (
+
+
+
+ )
+}
diff --git a/components/docs/previews/kbd.tsx b/components/docs/previews/kbd.tsx
new file mode 100644
index 0000000..0abc9cd
--- /dev/null
+++ b/components/docs/previews/kbd.tsx
@@ -0,0 +1,25 @@
+import { KbdCombo, type BuiltInColorScheme } from "@/registry/kbd/kbd"
+
+const showcase: { name: BuiltInColorScheme; keys: string[] }[] = [
+ { name: "dolch", keys: ["⌘", "C"] },
+ { name: "olivia", keys: ["⌘", "K"] },
+ { name: "botanical", keys: ["⌥", "↑"] },
+ { name: "laser", keys: ["Fn", "F1"] },
+ { name: "8008", keys: ["⇧", "P"] },
+ { name: "mizu", keys: ["⌘", "S"] },
+ { name: "dracula", keys: ["⌘", "D"] },
+ { name: "cream", keys: ["⌘", "V"] },
+]
+
+export default async function KbdPreview() {
+ return (
+
+ {showcase.map(({ name, keys }) => (
+
+
+ {name}
+
+ ))}
+
+ )
+}
diff --git a/components/docs/sidebar.tsx b/components/docs/sidebar.tsx
index afc4999..ba9dd62 100644
--- a/components/docs/sidebar.tsx
+++ b/components/docs/sidebar.tsx
@@ -3,13 +3,14 @@
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
-import { motion } from "motion/react"
+import { motion, useReducedMotion } from "motion/react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { docsNav, getActiveBadge } from "@/lib/docs"
export function Sidebar() {
const pathname = usePathname()
+ const prefersReducedMotion = useReducedMotion()
const scrollRef = React.useRef(null)
const [canScroll, setCanScroll] = React.useState(false)
@@ -69,7 +70,7 @@ export function Sidebar() {
key={item.href}
href={item.href}
className={cn(
- "relative flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:text-accent-foreground",
+ "relative flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
item.bundledIn && "ml-3 text-[13px]",
isActive
? "font-medium text-accent-foreground"
@@ -80,16 +81,16 @@ export function Sidebar() {
)}
{item.title}
{getActiveBadge(item) && (
-
+
{getActiveBadge(item)}
)}
@@ -115,12 +116,12 @@ export function Sidebar() {
onClick={() => {
scrollRef.current?.scrollBy({ top: 120, behavior: "smooth" })
}}
- className="flex w-full cursor-pointer flex-col items-center gap-0.5 bg-background pb-3 pt-1"
+ className="flex w-full cursor-pointer flex-col items-center gap-0.5 bg-background pb-3 pt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
More
-
+
diff --git a/components/changelog-image.tsx b/components/docs/theme-image.tsx
similarity index 68%
rename from components/changelog-image.tsx
rename to components/docs/theme-image.tsx
index 3d9f532..3018381 100644
--- a/components/changelog-image.tsx
+++ b/components/docs/theme-image.tsx
@@ -4,12 +4,17 @@ import * as React from "react"
import Image from "next/image"
import { useTheme } from "next-themes"
-interface ChangelogImageProps {
+interface ThemeImageProps {
slug: string
title: string
}
-export function ChangelogImage({ slug, title }: ChangelogImageProps) {
+/**
+ * Preview image that swaps between light and dark variants based on the
+ * active theme. Uses aspect-ratio for the SSR placeholder to avoid layout
+ * shift once the client hydrates.
+ */
+export function ThemeImage({ slug, title }: ThemeImageProps) {
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
diff --git a/components/docs/theme-switcher.tsx b/components/docs/theme-switcher.tsx
index 9b09695..01a41d0 100644
--- a/components/docs/theme-switcher.tsx
+++ b/components/docs/theme-switcher.tsx
@@ -137,7 +137,7 @@ export function ThemeSwitcher() {
setTheme(next)
track("theme_changed", { theme: next })
}}
- className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground"
+ className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label={
theme === "system"
? "System theme (click for light)"
@@ -160,7 +160,7 @@ export function ThemeSwitcher() {
Apply Theme
@@ -227,7 +227,7 @@ export function ThemeSwitcher() {
Reset
diff --git a/components/docs/variant-grid-client.tsx b/components/docs/variant-grid-client.tsx
index 77a6631..3dbd586 100644
--- a/components/docs/variant-grid-client.tsx
+++ b/components/docs/variant-grid-client.tsx
@@ -126,7 +126,7 @@ export function VariantGridClient({
aria-selected={activeFile === i}
onClick={() => setActiveFile(i)}
className={cn(
- "rounded-t-md px-3 py-2 font-mono text-xs transition-colors",
+ "rounded-t-md px-3 py-2 font-mono text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
activeFile === i
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -166,7 +166,7 @@ function VariantCell({
children: React.ReactNode
}) {
return (
-
+
{label}
diff --git a/lib/docs.ts b/lib/docs.ts
index 6d86ff9..396803d 100644
--- a/lib/docs.ts
+++ b/lib/docs.ts
@@ -47,6 +47,7 @@ export const docsNav: NavGroup[] = [
title: "Code",
items: [
{ title: "Code Block", href: "/docs/components/code-block", dateAdded: "2026-03-11" },
+ { title: "Diff Viewer", href: "/docs/components/diff-viewer", badge: "New", badgeAdded: "2026-03-24", dateAdded: "2026-03-24" },
{
title: "Code Block Command",
href: "/docs/components/code-block-command",
@@ -59,6 +60,7 @@ export const docsNav: NavGroup[] = [
title: "Docs",
items: [
{ title: "AI Copy Button", href: "/docs/components/ai-copy-button", dateAdded: "2026-03-11" },
+ { title: "Kbd", href: "/docs/components/kbd", badge: "New", badgeAdded: "2026-03-24", dateAdded: "2026-03-24" },
{ title: "API Reference Table", href: "/docs/components/api-ref-table", dateAdded: "2026-03-11" },
{ title: "File Tree", href: "/docs/components/file-tree", badge: "New", badgeAdded: "2026-03-17", dateAdded: "2026-03-17" },
{ title: "Stepper", href: "/docs/components/stepper", badge: "New", badgeAdded: "2026-03-12", dateAdded: "2026-03-12" },
diff --git a/package.json b/package.json
index 565cec2..ba3c341 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@types/mdx": "^2.0.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "diff": "^8.0.4",
"lucide-react": "^0.487.0",
"motion": "^12.36.0",
"next": "15.5.9",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bba97dc..dfd305e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -30,6 +30,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ diff:
+ specifier: ^8.0.4
+ version: 8.0.4
lucide-react:
specifier: ^0.487.0
version: 0.487.0(react@19.1.0)
@@ -2214,8 +2217,8 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
- diff@8.0.2:
- resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
doctrine@2.1.0:
@@ -6186,7 +6189,7 @@ snapshots:
dependencies:
dequal: 2.0.3
- diff@8.0.2: {}
+ diff@8.0.4: {}
doctrine@2.1.0:
dependencies:
@@ -7887,7 +7890,7 @@ snapshots:
cosmiconfig: 9.0.0(typescript@5.9.2)
dedent: 1.6.0
deepmerge: 4.3.1
- diff: 8.0.2
+ diff: 8.0.4
execa: 9.6.0
fast-glob: 3.3.3
fs-extra: 11.3.1
diff --git a/public/previews/diff-viewer-dark.png b/public/previews/diff-viewer-dark.png
new file mode 100644
index 0000000..6d7f2ac
Binary files /dev/null and b/public/previews/diff-viewer-dark.png differ
diff --git a/public/previews/diff-viewer-light.png b/public/previews/diff-viewer-light.png
new file mode 100644
index 0000000..18f1cec
Binary files /dev/null and b/public/previews/diff-viewer-light.png differ
diff --git a/public/previews/kbd-dark.png b/public/previews/kbd-dark.png
new file mode 100644
index 0000000..02a3ebb
Binary files /dev/null and b/public/previews/kbd-dark.png differ
diff --git a/public/previews/kbd-light.png b/public/previews/kbd-light.png
new file mode 100644
index 0000000..f42b9a5
Binary files /dev/null and b/public/previews/kbd-light.png differ
diff --git a/registry.json b/registry.json
index 3164782..a2f3258 100644
--- a/registry.json
+++ b/registry.json
@@ -385,6 +385,50 @@
"type": "registry:lib"
}
]
+ },
+ {
+ "name": "diff-viewer",
+ "type": "registry:component",
+ "title": "Diff Viewer",
+ "description": "Code diff viewer with line numbers and add/remove coloring. Supports unified and split layouts.",
+ "dependencies": [
+ "diff",
+ "lucide-react"
+ ],
+ "registryDependencies": [],
+ "categories": [
+ "code",
+ "dev-tools"
+ ],
+ "files": [
+ {
+ "path": "registry/diff-viewer/diff-viewer.tsx",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/diff-viewer/diff-viewer-client.tsx",
+ "type": "registry:component"
+ }
+ ]
+ },
+ {
+ "name": "kbd",
+ "type": "registry:component",
+ "title": "Kbd",
+ "description": "Keyboard shortcut key rendered as a styled keycap. Three visual profiles: flat, raised, and sculpted.",
+ "dependencies": [
+ "class-variance-authority"
+ ],
+ "registryDependencies": [],
+ "categories": [
+ "docs"
+ ],
+ "files": [
+ {
+ "path": "registry/kbd/kbd.tsx",
+ "type": "registry:component"
+ }
+ ]
}
]
}
diff --git a/registry/code-block/code-block.tsx b/registry/code-block/code-block.tsx
index fb83460..bb60489 100644
--- a/registry/code-block/code-block.tsx
+++ b/registry/code-block/code-block.tsx
@@ -15,6 +15,7 @@
* - overflow?: "default" | "scrollable" | "collapsible"
* - maxHeight?: max height for scrollable/collapsible modes
* - muted?: subdued visual treatment
+ * - compact?: hide the header bar, float copy button inside the code area
* - theme?: shiki theme name for single-theme rendering (e.g. "dracula", "nord")
*
* Dependencies: shiki, lucide-react
@@ -94,6 +95,8 @@ interface CodeBlockProps {
overflow?: "default" | "scrollable" | "collapsible"
maxHeight?: number
muted?: boolean
+ /** Hide the header bar. Copy button floats inside the code area. */
+ compact?: boolean
/** Shiki theme name for single-theme rendering (e.g. "dracula", "nord"). */
theme?: string
className?: string
@@ -107,6 +110,7 @@ export async function CodeBlock({
overflow = "default",
maxHeight,
muted = false,
+ compact = false,
theme,
className,
}: CodeBlockProps) {
@@ -137,47 +141,54 @@ export async function CodeBlock({
className
)}
>
-
-
- {resolvedIcon}
- {title ? (
-
- {title}
-
- ) : (
-
- {language}
-
+ {!compact && (
+
+
+ {resolvedIcon}
+ {title ? (
+
+ {title}
+
+ ) : (
+
+ {language}
+
+ )}
+
+
-
-
+ )}
+ {compact && (
+
+
+
+ )}
setCopied(false), 1500)
+ }
+
+ return (
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy"}
+
+ )
+}
diff --git a/registry/diff-viewer/diff-viewer.tsx b/registry/diff-viewer/diff-viewer.tsx
new file mode 100644
index 0000000..163d68d
--- /dev/null
+++ b/registry/diff-viewer/diff-viewer.tsx
@@ -0,0 +1,362 @@
+/**
+ * jalco-ui
+ * DiffViewer
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * Code diff viewer with line numbers and add/remove coloring.
+ * Supports unified (single column) and split (side-by-side) layouts.
+ *
+ * Props:
+ * - oldCode / newCode: two strings to diff (component computes the diff)
+ * - patch: pre-computed unified diff string (alternative to oldCode/newCode)
+ * - layout: "unified" | "split" (default "unified")
+ * - language: shiki language key for optional syntax highlighting
+ * - oldTitle / newTitle: labels for the file headers
+ * - className: additional CSS classes
+ *
+ * Dependencies: diff
+ */
+
+import * as React from "react"
+import { structuredPatch, parsePatch } from "diff"
+import { cn } from "@/lib/utils"
+import { DiffViewerCopyButton } from "@/registry/diff-viewer/diff-viewer-client"
+
+type DiffLayout = "unified" | "split"
+
+interface DiffLine {
+ type: "added" | "removed" | "context"
+ content: string
+ oldNumber: number | null
+ newNumber: number | null
+}
+
+interface WithStrings {
+ oldCode: string
+ newCode: string
+ patch?: never
+}
+
+interface WithPatch {
+ patch: string
+ oldCode?: never
+ newCode?: never
+}
+
+type DiffInput = WithStrings | WithPatch
+
+type DiffViewerProps = DiffInput & {
+ layout?: DiffLayout
+ /** Shiki language key for syntax highlighting. Plain text when omitted. */
+ language?: string
+ oldTitle?: string
+ newTitle?: string
+ className?: string
+}
+
+function computeLines(input: DiffInput): DiffLine[] {
+ let hunks: ReturnType
["hunks"]
+
+ if ("patch" in input && input.patch) {
+ const parsed = parsePatch(input.patch)
+ hunks = parsed[0]?.hunks ?? []
+ } else {
+ const result = structuredPatch(
+ "",
+ "",
+ input.oldCode ?? "",
+ input.newCode ?? "",
+ undefined,
+ undefined,
+ { context: 3 }
+ )
+ hunks = result.hunks
+ }
+
+ const lines: DiffLine[] = []
+
+ for (const hunk of hunks) {
+ let oldNum = hunk.oldStart
+ let newNum = hunk.newStart
+
+ for (const line of hunk.lines) {
+ if (line.startsWith("+")) {
+ lines.push({
+ type: "added",
+ content: line.slice(1),
+ oldNumber: null,
+ newNumber: newNum++,
+ })
+ } else if (line.startsWith("-")) {
+ lines.push({
+ type: "removed",
+ content: line.slice(1),
+ oldNumber: oldNum++,
+ newNumber: null,
+ })
+ } else {
+ lines.push({
+ type: "context",
+ content: line.startsWith(" ") ? line.slice(1) : line,
+ oldNumber: oldNum++,
+ newNumber: newNum++,
+ })
+ }
+ }
+ }
+
+ return lines
+}
+
+function lineNumberWidth(lines: DiffLine[]): number {
+ let max = 0
+ for (const line of lines) {
+ if (line.oldNumber && line.oldNumber > max) max = line.oldNumber
+ if (line.newNumber && line.newNumber > max) max = line.newNumber
+ }
+ return Math.max(String(max).length, 2)
+}
+
+function lineColor(type: DiffLine["type"], element: "bg" | "text" | "num") {
+ if (type === "added") {
+ return element === "bg"
+ ? "bg-emerald-500/10 dark:bg-emerald-500/10"
+ : element === "num"
+ ? "text-emerald-700/70 dark:text-emerald-400/50"
+ : "text-emerald-900 dark:text-emerald-200"
+ }
+ if (type === "removed") {
+ return element === "bg"
+ ? "bg-red-500/10 dark:bg-red-500/10"
+ : element === "num"
+ ? "text-red-700/70 dark:text-red-400/50"
+ : "text-red-900 dark:text-red-200"
+ }
+ return element === "num"
+ ? "text-muted-foreground/50"
+ : "text-foreground/80"
+}
+
+function linePrefix(type: DiffLine["type"]) {
+ if (type === "added") return "+"
+ if (type === "removed") return "-"
+ return " "
+}
+
+function UnifiedView({ lines, numWidth }: { lines: DiffLine[]; numWidth: number }) {
+ return (
+
+
+ {lines.map((line, i) => (
+
+
+ {line.oldNumber ?? ""}
+
+
+ {line.newNumber ?? ""}
+
+
+ {linePrefix(line.type)}
+
+
+ {line.content || "\u00A0"}
+
+
+ ))}
+
+
+ )
+}
+
+function SplitView({ lines, numWidth }: { lines: DiffLine[]; numWidth: number }) {
+ const leftLines: (DiffLine | null)[] = []
+ const rightLines: (DiffLine | null)[] = []
+
+ let i = 0
+ while (i < lines.length) {
+ const line = lines[i]
+
+ if (line.type === "context") {
+ leftLines.push(line)
+ rightLines.push(line)
+ i++
+ } else if (line.type === "removed") {
+ const removed: DiffLine[] = []
+ while (i < lines.length && lines[i].type === "removed") {
+ removed.push(lines[i])
+ i++
+ }
+ const added: DiffLine[] = []
+ while (i < lines.length && lines[i].type === "added") {
+ added.push(lines[i])
+ i++
+ }
+
+ const maxLen = Math.max(removed.length, added.length)
+ for (let j = 0; j < maxLen; j++) {
+ leftLines.push(j < removed.length ? removed[j] : null)
+ rightLines.push(j < added.length ? added[j] : null)
+ }
+ } else if (line.type === "added") {
+ leftLines.push(null)
+ rightLines.push(line)
+ i++
+ } else {
+ i++
+ }
+ }
+
+ return (
+
+
+
+
+ {leftLines.map((line, idx) => (
+
+
+ {line?.oldNumber ?? ""}
+
+
+ {line?.content || "\u00A0"}
+
+
+ ))}
+
+
+
+
+
+
+ {rightLines.map((line, idx) => (
+
+
+ {line?.newNumber ?? ""}
+
+
+ {line?.content || "\u00A0"}
+
+
+ ))}
+
+
+
+
+ )
+}
+
+export function DiffViewer({
+ layout = "unified",
+ oldTitle,
+ newTitle,
+ className,
+ ...input
+}: DiffViewerProps) {
+ const lines = computeLines(input as DiffInput)
+ const numWidth = lineNumberWidth(lines)
+
+ const stats = lines.reduce(
+ (acc, l) => {
+ if (l.type === "added") acc.added++
+ if (l.type === "removed") acc.removed++
+ return acc
+ },
+ { added: 0, removed: 0 }
+ )
+
+ const fullCode = lines.map((l) => l.content).join("\n")
+ const showHeader = oldTitle || newTitle
+
+ return (
+
+ {showHeader && (
+
+
+ {oldTitle && (
+ {oldTitle}
+ )}
+ {oldTitle && newTitle && (
+ →
+ )}
+ {newTitle && (
+ {newTitle}
+ )}
+
+
+
+ {stats.added > 0 && (
+
+ +{stats.added}
+
+ )}
+ {stats.removed > 0 && (
+
+ -{stats.removed}
+
+ )}
+
+
+
+
+ )}
+
+ {!showHeader && (
+
+
+
+ )}
+
+
+ {layout === "split" ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/registry/kbd/kbd.tsx b/registry/kbd/kbd.tsx
new file mode 100644
index 0000000..f2bc09a
--- /dev/null
+++ b/registry/kbd/kbd.tsx
@@ -0,0 +1,191 @@
+/**
+ * jalco-ui
+ * Kbd
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * Keyboard shortcut key rendered as a styled keycap. Three visual profiles
+ * inspired by real keycap shapes: flat, raised, and sculpted. Optional color
+ * schemes inspired by popular keycap sets.
+ *
+ * Props:
+ * - children: key label (e.g. "⌘", "K", "Shift")
+ * - variant: "flat" | "raised" | "sculpted" (default "raised")
+ * - size: "sm" | "md" | "lg" (default "md")
+ * - colorScheme: named palette or custom { bg, text, border } object
+ * - className: additional CSS classes
+ *
+ * Compose multiple elements inline for key combos.
+ */
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+
+interface KbdColorScheme {
+ bg: string
+ text: string
+ border: string
+}
+
+const builtInSchemes: Record = {
+ dolch: { bg: "#2D2D2D", text: "#868686", border: "#1A1A1A" },
+ olivia: { bg: "#1C1C1C", text: "#E8B4B8", border: "#111111" },
+ botanical: { bg: "#D5C4A1", text: "#2D4A3E", border: "#B8A88A" },
+ oblivion: { bg: "#36393E", text: "#868B8E", border: "#25272B" },
+ "8008": { bg: "#333333", text: "#F472B6", border: "#222222" },
+ laser: { bg: "#2B1B54", text: "#FF71CE", border: "#1A1040" },
+ mizu: { bg: "#E8E8E8", text: "#2E5D8A", border: "#CFCFCF" },
+ dracula: { bg: "#282A36", text: "#F8F8F2", border: "#1A1B24" },
+ hammerhead: { bg: "#1A1A2E", text: "#16C79A", border: "#0F0F1E" },
+ wob: { bg: "#1A1A1A", text: "#FFFFFF", border: "#0D0D0D" },
+ bow: { bg: "#F5F5F5", text: "#1A1A1A", border: "#DCDCDC" },
+ cream: { bg: "#F5E6C8", text: "#5C4A32", border: "#E0D0B0" },
+}
+
+type BuiltInColorScheme = keyof typeof builtInSchemes
+
+function resolveScheme(
+ scheme: BuiltInColorScheme | KbdColorScheme | undefined
+): KbdColorScheme | null {
+ if (!scheme) return null
+ if (typeof scheme === "string") return builtInSchemes[scheme] ?? null
+ return scheme
+}
+
+const kbdVariants = cva(
+ "inline-flex items-center justify-center font-mono font-medium leading-none select-none",
+ {
+ variants: {
+ variant: {
+ flat: ["rounded-md border border-border/80 bg-transparent text-foreground/80"],
+ raised: [
+ "rounded-md border border-border bg-muted/60 text-foreground",
+ "border-b-[2px] border-b-border",
+ "shadow-[0_1px_0_0_var(--color-border),inset_0_1px_0_0_rgba(255,255,255,0.06)]",
+ ],
+ sculpted: [
+ "rounded-lg text-foreground",
+ "border border-border/80 border-b-[3px] border-b-border",
+ "bg-gradient-to-b from-muted/80 to-muted/40",
+ "shadow-[0_2px_0_0_var(--color-border),0_3px_6px_-2px_rgba(0,0,0,0.15),inset_0_1px_0_0_rgba(255,255,255,0.1)]",
+ ],
+ },
+ size: {
+ sm: "min-h-5 min-w-5 px-1 text-[10px]",
+ md: "min-h-6 min-w-6 px-1.5 text-[11px]",
+ lg: "min-h-8 min-w-8 px-2 text-xs",
+ },
+ },
+ defaultVariants: {
+ variant: "raised",
+ size: "md",
+ },
+ }
+)
+
+type KbdVariantProps = VariantProps
+
+interface KbdProps
+ extends React.HTMLAttributes,
+ KbdVariantProps {
+ children: React.ReactNode
+ /** Named color scheme or custom { bg, text, border } palette. */
+ colorScheme?: BuiltInColorScheme | KbdColorScheme
+}
+
+function colorStyles(
+ scheme: KbdColorScheme | null,
+ variant: KbdVariantProps["variant"]
+): React.CSSProperties | undefined {
+ if (!scheme) return undefined
+
+ const base: React.CSSProperties = {
+ color: scheme.text,
+ borderColor: scheme.border,
+ }
+
+ if (variant === "flat") {
+ return { ...base, background: "transparent" }
+ }
+
+ if (variant === "sculpted") {
+ return {
+ ...base,
+ background: `linear-gradient(to bottom, ${scheme.bg}, ${scheme.border})`,
+ borderBottomColor: scheme.border,
+ boxShadow: `0 2px 0 0 ${scheme.border}, 0 3px 6px -2px rgba(0,0,0,0.25), inset 0 1px 0 0 rgba(255,255,255,0.1)`,
+ }
+ }
+
+ // raised
+ return {
+ ...base,
+ background: scheme.bg,
+ borderBottomColor: scheme.border,
+ boxShadow: `0 1px 0 0 ${scheme.border}, inset 0 1px 0 0 rgba(255,255,255,0.06)`,
+ }
+}
+
+function Kbd({
+ children,
+ variant,
+ size,
+ colorScheme,
+ className,
+ style,
+ ...props
+}: KbdProps) {
+ const resolved = resolveScheme(colorScheme)
+
+ return (
+
+ {children}
+
+ )
+}
+
+interface KbdComboProps {
+ /** Array of key labels to render as a combo (e.g. ["⌘", "Shift", "K"]). */
+ keys: string[]
+ /** Separator between keys. Defaults to no separator (keys are adjacent). */
+ separator?: React.ReactNode
+ variant?: KbdVariantProps["variant"]
+ size?: KbdVariantProps["size"]
+ /** Named color scheme or custom { bg, text, border } palette. */
+ colorScheme?: BuiltInColorScheme | KbdColorScheme
+ className?: string
+}
+
+function KbdCombo({
+ keys,
+ separator,
+ variant,
+ size,
+ colorScheme,
+ className,
+}: KbdComboProps) {
+ return (
+
+ {keys.map((key, i) => (
+
+ {i > 0 && separator !== undefined && (
+
+ {separator}
+
+ )}
+
+ {key}
+
+
+ ))}
+
+ )
+}
+
+export { Kbd, KbdCombo, kbdVariants, builtInSchemes }
+export type { KbdProps, KbdComboProps, KbdColorScheme, BuiltInColorScheme }