├── app ├── favicon.ico ├── robots.ts ├── sitemap.ts ├── (home) │ ├── page.tsx │ └── sections │ │ ├── variants.tsx │ │ ├── setup.tsx │ │ └── hero.tsx ├── layout.tsx └── globals.css ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg ├── next.svg ├── r │ └── font-picker.json └── startup-listing.svg ├── lib ├── utils.ts └── fonts.ts ├── types └── index.d.ts ├── next.config.ts ├── components ├── ui │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── label.tsx │ ├── input.tsx │ ├── hover-card.tsx │ ├── badge.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── tabs.tsx │ ├── accordion.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── form.tsx │ ├── command.tsx │ ├── select.tsx │ ├── dropdown-menu.tsx │ └── font-picker.tsx ├── providers.tsx ├── snippet.tsx ├── code-block.tsx ├── theme-toggle.tsx ├── pre.tsx ├── copy-button.tsx ├── footer.tsx └── header.tsx ├── config └── site.ts ├── eslint.config.mjs ├── components.json ├── .gitignore ├── prettier.config.mjs ├── registry.json ├── tsconfig.json ├── contentlayer.config.ts ├── LICENSE.md ├── hooks └── use-copy.tsx ├── .commitlintrc.js ├── package.json ├── README.md └── content └── snippets ├── fonts.mdx └── font-picker.mdx /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevinodpatidar/shadcn-font-picker/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type SiteConfig = { 2 | name: string; 3 | description: string; 4 | url: string; 5 | links: { 6 | twitter: string; 7 | github: string; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | const { withContentlayer } = require("next-contentlayer"); 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { reactStrictMode: true, swcMinify: true }; 5 | 6 | module.exports = withContentlayer(nextConfig); 7 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | 3 | import { siteConfig } from "../config/site"; 4 | 5 | export default function robots(): MetadataRoute.Robots { 6 | return { 7 | rules: { 8 | userAgent: "*", 9 | allow: "/", 10 | disallow: "/private/", 11 | }, 12 | sitemap: `${siteConfig.url}/sitemap.xml`, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/site.ts: -------------------------------------------------------------------------------- 1 | import { SiteConfig } from "@/types"; 2 | 3 | export const siteConfig: SiteConfig = { 4 | name: "Shadcn Font Picker", 5 | description: 6 | "A font picker component implementation of Shadcn's input component", 7 | url: "https://shadcn-font-picker.vercel.app", 8 | links: { 9 | twitter: "https://twitter.com/thevinodpatidar", 10 | github: "https://github.com/thevinodpatidar/shadcn-font-picker", 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider } from "next-themes"; 4 | 5 | export function Providers({ children }: { children: React.ReactNode }) { 6 | 7 | return ( 8 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /components/snippet.tsx: -------------------------------------------------------------------------------- 1 | 2 | "use client"; 3 | 4 | import React from "react"; 5 | import { useMDXComponent } from "next-contentlayer/hooks"; 6 | 7 | import type { Snippet as SnippetType } from ".contentlayer/generated"; 8 | import Pre from "./pre"; 9 | 10 | const components = { 11 | pre: Pre, 12 | }; 13 | 14 | export function Snippet({ snippet }: { snippet: SnippetType }) { 15 | const MDXContent = useMDXComponent(snippet.body.code); 16 | return ; 17 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # contentlayer 38 | .contentlayer -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { type MetadataRoute } from "next"; 2 | 3 | import { siteConfig } from "../config/site"; 4 | 5 | export default function sitemap(): MetadataRoute.Sitemap { 6 | return [ 7 | { 8 | url: `${siteConfig.url}/`, 9 | lastModified: new Date(), 10 | }, 11 | { 12 | url: `${siteConfig.url}/#try`, 13 | lastModified: new Date(), 14 | }, 15 | { 16 | url: `${siteConfig.url}/#setup`, 17 | lastModified: new Date(), 18 | }, 19 | { 20 | url: `${siteConfig.url}/#variants`, 21 | lastModified: new Date(), 22 | }, 23 | ]; 24 | } 25 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @typedef {import("prettier").Config} PrettierConfig */ 2 | /** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */ 3 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ 4 | 5 | /** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */ 6 | const config = { 7 | plugins: [ 8 | "@ianvs/prettier-plugin-sort-imports", 9 | "prettier-plugin-tailwindcss", 10 | ], 11 | tailwindFunctions: ["cn", "cva"], 12 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 13 | importOrderTypeScriptVersion: "4.4.0", 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /registry.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema/registry.json", 3 | "name": "shadcn-font-picker", 4 | "homepage": "https://shadcn-font-picker.vercel.app", 5 | "items": [ 6 | { 7 | "name": "font-picker", 8 | "type": "registry:block", 9 | "title": "Font Picker", 10 | "description": "A font picker component.", 11 | "registryDependencies": ["button", "command", "dropdown-menu", "popover"], 12 | "dependencies": ["react-window", "@types/react-window"], 13 | "files": [ 14 | { 15 | "path": "components/ui/font-picker.tsx", 16 | "type": "registry:component" 17 | }, 18 | { 19 | "path": "lib/fonts.ts", 20 | "type": "registry:lib" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /components/code-block.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | import CopyButton from "./copy-button"; 4 | 5 | function CodeBlock({ 6 | value, 7 | className, 8 | copyable = true, 9 | }: { 10 | value: string; 11 | className?: string; 12 | codeClass?: string; 13 | copyable?: boolean; 14 | codeWrap?: boolean; 15 | noCodeFont?: boolean; 16 | noMask?: boolean; 17 | }) { 18 | value = value || ""; 19 | 20 | return ( 21 |
28 |       
29 |       {value}
30 |     
31 | ); 32 | } 33 | 34 | export default CodeBlock; 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "baseUrl": ".", 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"], 24 | "contentlayer/generated": ["./.contentlayer/generated"] 25 | } 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx", 31 | ".next/types/**/*.ts", 32 | ".contentlayer/generated" 33 | ], 34 | "exclude": ["node_modules"] 35 | } 36 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /contentlayer.config.ts: -------------------------------------------------------------------------------- 1 | import { defineDocumentType, makeSource } from "contentlayer/source-files"; 2 | 3 | export const Snippet = defineDocumentType(() => ({ 4 | name: "Snippet", 5 | filePathPattern: `snippets/**/*.mdx`, 6 | contentType: "mdx", 7 | fields: { 8 | file: { 9 | type: "string", 10 | description: "The name of the snippet", 11 | required: true, 12 | }, 13 | filePath: { 14 | type: "string", 15 | description: "The path of the snippet", 16 | required: true, 17 | }, 18 | order: { 19 | type: "number", 20 | description: "The order of the snippet", 21 | required: true, 22 | }, 23 | description: { 24 | type: "string", 25 | description: "The description of the snippet", 26 | required: true, 27 | }, 28 | }, 29 | computedFields: { 30 | slug: { 31 | type: "string", 32 | resolve: (_) => _._raw.sourceFileName.replace(/\.[^.$]+$/, ""), 33 | }, 34 | }, 35 | })); 36 | 37 | export default makeSource({ 38 | contentDirPath: "content", 39 | documentTypes: [Snippet], 40 | }); -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2025 Vinod Patidar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /hooks/use-copy.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | function useClipboard() { 4 | const [isCopied, setIsCopied] = useState(false); 5 | 6 | const copyToClipboard = useCallback(async (text: string) => { 7 | if (navigator.clipboard && window.isSecureContext) { 8 | // Navigator Clipboard API method' 9 | try { 10 | await navigator.clipboard.writeText(text); 11 | setIsCopied(true); 12 | } catch (err) { 13 | console.error(err); 14 | setIsCopied(false); 15 | } 16 | } else { 17 | // Clipboard API not available, use fallback 18 | const textArea = document.createElement("textarea"); 19 | textArea.value = text; 20 | document.body.appendChild(textArea); 21 | textArea.focus(); 22 | textArea.select(); 23 | try { 24 | const successful: boolean = document.execCommand("copy"); 25 | setIsCopied(successful); 26 | } catch (err) { 27 | console.error(err); 28 | setIsCopied(false); 29 | } 30 | document.body.removeChild(textArea); 31 | } 32 | }, []); 33 | 34 | return { isCopied, copyToClipboard }; 35 | } 36 | 37 | export default useClipboard; 38 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | rules: { 4 | "type-enum": [ 5 | 2, 6 | "always", 7 | [ 8 | // Changes that affect the build system or dependency-only changes 9 | "build", 10 | // Changes to CI workflows 11 | "ci", 12 | // Documentation-only changes 13 | "docs", 14 | // A new feature 15 | "feat", 16 | //A bug fix 17 | "fix", 18 | // A code change that improves performance 19 | "perf", 20 | // A code change that neither fixes a bug nor adds a feature 21 | "refactor", 22 | // A commit that reverts a previous commit 23 | "revert", 24 | // Changes that do not affect the meaning of the code 25 | "style", 26 | // Adding missing tests or correcting existing tests 27 | "test", 28 | ], 29 | ], 30 | "scope-enum": [ 31 | 2, 32 | "always", 33 | [ 34 | // Dependency-related changes 35 | "deps", 36 | // ESLint-related changes 37 | "eslint", 38 | // Prettier-related changes 39 | "prettier", 40 | // TypeScript-related changes 41 | "typescript", 42 | // Go-related changes 43 | "golang", 44 | ], 45 | ], 46 | "scope-empty": [1, "never"], 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Moon, Sun } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | 14 | export function ThemeToggle() { 15 | const { setTheme } = useTheme(); 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | Light 29 | 30 | setTheme("dark")}> 31 | Dark 32 | 33 | setTheme("system")}> 34 | System 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function HoverCard({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function HoverCardTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return ( 18 | 19 | ) 20 | } 21 | 22 | function HoverCardContent({ 23 | className, 24 | align = "center", 25 | sideOffset = 4, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 30 | 40 | 41 | ) 42 | } 43 | 44 | export { HoverCard, HoverCardTrigger, HoverCardContent } 45 | -------------------------------------------------------------------------------- /components/pre.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Check, Copy } from "lucide-react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | import { Button } from "./ui/button"; 7 | 8 | export default function Pre({ 9 | children, 10 | className, 11 | ...props 12 | }: React.HTMLAttributes) { 13 | const [copied, setCopied] = React.useState(false); 14 | const ref = React.useRef(null); 15 | 16 | React.useEffect(() => { 17 | let timer: ReturnType; 18 | if (copied) { 19 | timer = setTimeout(() => { 20 | setCopied(false); 21 | }, 2000); 22 | } 23 | return () => { 24 | clearTimeout(timer); 25 | }; 26 | }, [copied]); 27 | 28 | const onClick = () => { 29 | setCopied(true); 30 | const content = ref.current?.textContent; 31 | if (content) { 32 | navigator.clipboard.writeText(content); 33 | } 34 | }; 35 | 36 | return ( 37 |
38 | 48 |
56 |         {children}
57 |       
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Hero from "@/app/(home)/sections/hero"; 4 | import Setup from "@/app/(home)/sections/setup"; 5 | import { Header } from "@/components/header"; 6 | import { siteConfig } from "@/config/site"; 7 | import Variants from "./sections/variants"; 8 | 9 | export default function Home() { 10 | return ( 11 |
12 |
13 |
14 |
15 | 16 | 17 | 18 |
19 |
20 | 48 |
49 | ); 50 | } -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 49 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function ScrollArea({ 9 | className, 10 | children, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 19 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function ScrollBar({ 32 | className, 33 | orientation = "vertical", 34 | ...props 35 | }: React.ComponentProps) { 36 | return ( 37 | 50 | 54 | 55 | ) 56 | } 57 | 58 | export { ScrollArea, ScrollBar } 59 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Providers } from "@/components/providers"; 2 | import { Toaster } from "@/components/ui/sonner"; 3 | import type { Metadata } from "next"; 4 | import { Geist, Geist_Mono } from "next/font/google"; 5 | import "./globals.css"; 6 | 7 | const geistSans = Geist({ 8 | variable: "--font-geist-sans", 9 | subsets: ["latin"], 10 | }); 11 | 12 | const geistMono = Geist_Mono({ 13 | variable: "--font-geist-mono", 14 | subsets: ["latin"], 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: "Shadcn Font Picker - Beautiful Font Selection Component", 19 | description: 20 | "A beautiful and customizable font picker component built with shadcn/ui and Google Fonts API. Perfect for modern web applications.", 21 | keywords: [ 22 | "shadcn", 23 | "font picker", 24 | "react", 25 | "component", 26 | "google fonts", 27 | "ui", 28 | ], 29 | alternates: { canonical: "https://shadcn-font-picker.vercel.app/" }, 30 | authors: [{ name: "Vinod Patidar" }], 31 | openGraph: { 32 | title: "Shadcn Font Picker", 33 | description: 34 | "A beautiful font picker component built with shadcn/ui and Google Fonts API", 35 | type: "website", 36 | url: "https://shadcn-font-picker.vercel.app/", 37 | siteName: "Shadcn Font Picker", 38 | }, 39 | twitter: { 40 | card: "summary_large_image", 41 | title: "Shadcn Font Picker", 42 | description: 43 | "A beautiful font picker component built with shadcn/ui and Google Fonts API", 44 | creator: "@thevinodpatidar", 45 | }, 46 | icons: { icon: "/favicon.ico" }, 47 | verification: { 48 | google: "Hz1IFTnXjR3j5H80jC25eENAjgzEhbatuRNeg46tTow", 49 | }, 50 | }; 51 | 52 | export default function RootLayout({ 53 | children, 54 | }: Readonly<{ 55 | children: React.ReactNode; 56 | }>) { 57 | return ( 58 | 59 | 62 | {children} 63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /components/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { AnimatePresence, motion, MotionConfig } from "framer-motion"; 3 | import { Check, Copy } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | import { Button } from "./ui/button"; 8 | 9 | export default function CopyButton({ 10 | value, 11 | }: { 12 | value: string; 13 | copyable?: boolean; 14 | }) { 15 | const [copying, setCopying] = useState(0); 16 | 17 | const onCopy = useCallback(async () => { 18 | try { 19 | await navigator.clipboard.writeText(value); 20 | setCopying((c) => c + 1); 21 | setTimeout(() => { 22 | setCopying((c) => c - 1); 23 | }, 2000); 24 | } catch (err) { 25 | console.error("Failed to copy text: ", err); 26 | } 27 | }, [value]); 28 | 29 | const variants = { 30 | visible: { opacity: 1, scale: 1 }, 31 | hidden: { opacity: 0, scale: 0.5 }, 32 | }; 33 | 34 | return ( 35 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Tabs({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function TabsList({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | function TabsTrigger({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | function TabsContent({ 54 | className, 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 63 | ) 64 | } 65 | 66 | export { Tabs, TabsList, TabsTrigger, TabsContent } 67 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDownIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Accordion({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AccordionItem({ 16 | className, 17 | ...props 18 | }: React.ComponentProps) { 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | function AccordionTrigger({ 29 | className, 30 | children, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 35 | svg]:rotate-180", 39 | className 40 | )} 41 | {...props} 42 | > 43 | {children} 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | function AccordionContent({ 51 | className, 52 | children, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 |
{children}
62 |
63 | ) 64 | } 65 | 66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 67 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Github, Twitter } from "lucide-react"; 5 | 6 | export function Footer() { 7 | return ( 8 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shadcn-font-picker", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "contentlayer build && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "lint:fix": "next lint --fix", 11 | "typecheck": "tsc --noEmit", 12 | "format": "prettier --write .", 13 | "prepare": "husky", 14 | "preinstall": "npx only-allow yarn", 15 | "registry:build": "shadcn build" 16 | }, 17 | "dependencies": { 18 | "@hookform/resolvers": "^5.0.1", 19 | "@radix-ui/react-accordion": "^1.2.4", 20 | "@radix-ui/react-dialog": "^1.1.7", 21 | "@radix-ui/react-dropdown-menu": "^2.1.7", 22 | "@radix-ui/react-hover-card": "^1.1.7", 23 | "@radix-ui/react-label": "^2.1.3", 24 | "@radix-ui/react-popover": "^1.1.7", 25 | "@radix-ui/react-scroll-area": "^1.2.4", 26 | "@radix-ui/react-select": "^2.1.7", 27 | "@radix-ui/react-slot": "^1.2.3", 28 | "@radix-ui/react-tabs": "^1.1.4", 29 | "class-variance-authority": "^0.7.1", 30 | "clsx": "^2.1.1", 31 | "cmdk": "^1.1.1", 32 | "contentlayer": "^0.3.4", 33 | "framer-motion": "^12.7.2", 34 | "lucide-react": "^0.488.0", 35 | "next": "15.3.0", 36 | "next-contentlayer": "^0.3.4", 37 | "next-themes": "^0.4.6", 38 | "react": "^19.0.0", 39 | "react-dom": "^19.0.0", 40 | "react-hook-form": "^7.55.0", 41 | "react-window": "^1.8.11", 42 | "shadcn": "^2.4.0-canary.20", 43 | "sonner": "^2.0.3", 44 | "tailwind-merge": "^3.2.0", 45 | "tw-animate-css": "^1.2.5", 46 | "zod": "^3.24.2" 47 | }, 48 | "devDependencies": { 49 | "@commitlint/cli": "^19.6.0", 50 | "@commitlint/config-conventional": "^19.6.0", 51 | "@eslint/eslintrc": "^3", 52 | "@ianvs/prettier-plugin-sort-imports": "^4.4.0", 53 | "@tailwindcss/postcss": "^4", 54 | "@types/eslint": "^8.56.7", 55 | "@types/node": "^20", 56 | "@types/react": "^19", 57 | "@types/react-dom": "^19", 58 | "@types/react-window": "^1.8.8", 59 | "@typescript-eslint/eslint-plugin": "^7.6.0", 60 | "@typescript-eslint/parser": "^7.6.0", 61 | "eslint": "^9", 62 | "eslint-config-next": "15.3.0", 63 | "eslint-config-prettier": "^9.1.0", 64 | "eslint-plugin-tailwindcss": "^3.15.1", 65 | "husky": "^9.1.7", 66 | "lint-staged": "^15.2.10", 67 | "postcss": "8.4.38", 68 | "prettier": "^3.3.3", 69 | "prettier-plugin-tailwindcss": "^0.6.9", 70 | "rehype": "^13.0.1", 71 | "rehype-pretty-code": "^0.13.1", 72 | "shiki": "^1.3.0", 73 | "tailwindcss": "^4", 74 | "typescript": "^5", 75 | "unist-builder": "4.0.0", 76 | "unist-util-visit": "^5.0.0" 77 | }, 78 | "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" 79 | } 80 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { siteConfig } from "@/config/site"; 5 | import { Moon, Star, Sun } from "lucide-react"; 6 | import { useTheme } from "next-themes"; 7 | import Link from "next/link"; 8 | 9 | 10 | export function Header() { 11 | const { setTheme, theme } = useTheme(); 12 | 13 | const handleThemeToggle = () => { 14 | setTheme(theme === "dark" ? "light" : "dark"); 15 | }; 16 | 17 | return ( 18 |
19 |
20 |
21 | 22 |
23 | F 24 |
25 | Font Picker 26 | 27 |
28 | 29 |
30 | 41 |
42 | 52 | 63 |
64 |
65 |
66 |
67 | ); 68 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google Font Picker Component for shadcn/ui 2 | 3 | A beautiful and functional Google Font picker component built with shadcn/ui. This component allows users to search, filter, and preview Google Fonts directly in their application with optimized performance using virtualized rendering. 4 | 5 | ## Features 6 | 7 | - 🔍 Search fonts by name 8 | - 🗂️ Filter fonts by category (serif, sans-serif, display, handwriting, monospace) 9 | - 👀 Live font preview with smooth loading 10 | - 📱 Responsive design 11 | - ♿ Accessible UI components 12 | - 🎨 Customizable styling 13 | - ⚡ Virtualized list rendering for optimal performance 14 | - 🎯 Configurable dimensions and appearance 15 | 16 | ## Prerequisites 17 | 18 | Before using this component, make sure you have: 19 | 20 | 1. A Next.js project with shadcn/ui set up 21 | 2. A Google Fonts API key (get one from [Google Cloud Console](https://console.cloud.google.com/)) 22 | 23 | ## Installation 24 | 25 | 1. Add the required dependencies: 26 | 27 | ```bash 28 | npm install lucide-react react-window 29 | ``` 30 | 31 | 2. Install the required shadcn/ui components: 32 | 33 | ```bash 34 | npx shadcn-ui@latest add button command dropdown-menu popover 35 | ``` 36 | 37 | 3. Set up your environment variables by creating a `.env.local` file: 38 | 39 | ```env 40 | NEXT_PUBLIC_GOOGLE_FONTS_API_KEY=your_google_fonts_api_key_here 41 | ``` 42 | 43 | ## Usage 44 | 45 | 1. Import the FontPicker component: 46 | 47 | ```tsx 48 | import { FontPicker } from "@/components/ui/font-picker"; 49 | ``` 50 | 51 | 2. Use the FontPicker component: 52 | 53 | ```tsx 54 | export default function MyComponent() { 55 | const [selectedFont, setSelectedFont] = useState(); 56 | 57 | return ( 58 | 66 | ); 67 | } 68 | ``` 69 | 70 | ## Component API 71 | 72 | ### FontPicker Props 73 | 74 | | Prop | Type | Default | Description | 75 | | ------------- | ------------------------ | ------- | ------------------------------------------------ | 76 | | `value` | `string` | - | The currently selected font family | 77 | | `onChange` | `(font: string) => void` | - | Callback function called when a font is selected | 78 | | `width` | `number` | 300 | Width of the picker component | 79 | | `height` | `number` | 400 | Height of the picker component | 80 | | `className` | `string` | - | Additional CSS classes for customization | 81 | | `showFilters` | `boolean` | true | Whether to show the category filter | 82 | 83 | ### GoogleFont Type 84 | 85 | ```ts 86 | interface GoogleFont { 87 | family: string; 88 | variants: string[]; 89 | subsets: string[]; 90 | version: string; 91 | lastModified: string; 92 | files: Record; 93 | category: string; 94 | kind: string; 95 | menu: string; 96 | } 97 | ``` 98 | 99 | ## License 100 | 101 | MIT 102 | -------------------------------------------------------------------------------- /lib/fonts.ts: -------------------------------------------------------------------------------- 1 | export interface GoogleFont { 2 | family: string; 3 | variants: string[]; 4 | subsets: string[]; 5 | version: string; 6 | lastModified: string; 7 | files: Record; 8 | category: string; 9 | kind: string; 10 | } 11 | 12 | const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_FONTS_API_KEY; 13 | const API_URL = "https://www.googleapis.com/webfonts/v1/webfonts"; 14 | 15 | // Cache for loaded font stylesheets 16 | const loadedFonts = new Set(); 17 | 18 | // Cache for the Google Fonts API response 19 | let fontsCache: GoogleFont[] | null = null; 20 | let fontsCacheTimestamp: number | null = null; 21 | const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 22 | 23 | export async function fetchGoogleFonts(): Promise { 24 | // Check if we have a valid cache 25 | if ( 26 | fontsCache && 27 | fontsCacheTimestamp && 28 | Date.now() - fontsCacheTimestamp < CACHE_DURATION 29 | ) { 30 | return fontsCache; 31 | } 32 | 33 | if (!API_KEY) { 34 | throw new Error("Google Fonts API key is not configured"); 35 | } 36 | 37 | try { 38 | const response = await fetch(`${API_URL}?key=${API_KEY}&sort=popularity`); 39 | if (!response.ok) { 40 | throw new Error("Failed to fetch Google Fonts"); 41 | } 42 | const data = await response.json(); 43 | fontsCache = data.items; 44 | fontsCacheTimestamp = Date.now(); 45 | return data.items; 46 | } catch (error) { 47 | // If fetch fails and we have a cache, return it even if expired 48 | if (fontsCache) { 49 | return fontsCache; 50 | } 51 | console.error("Error fetching Google Fonts:", error); 52 | throw error; 53 | } 54 | } 55 | 56 | export function getFontUrl(font: GoogleFont, variant = "regular"): string { 57 | const fontFamily = font.family.replace(/\s+/g, "+"); 58 | const fontVariant = variant === "regular" ? "400" : variant; 59 | return `https://fonts.googleapis.com/css2?family=${fontFamily}:wght@${fontVariant}&display=swap`; 60 | } 61 | 62 | export async function loadFont( 63 | fontFamily: string, 64 | variant = "regular", 65 | ): Promise { 66 | if (loadedFonts.has(fontFamily)) { 67 | return; 68 | } 69 | 70 | return new Promise((resolve, reject) => { 71 | const link = document.createElement("link"); 72 | link.href = getFontUrl({ family: fontFamily } as GoogleFont, variant); 73 | link.rel = "stylesheet"; 74 | 75 | link.onload = () => { 76 | loadedFonts.add(fontFamily); 77 | resolve(); 78 | }; 79 | 80 | link.onerror = () => { 81 | reject(new Error(`Failed to load font: ${fontFamily}`)); 82 | }; 83 | 84 | document.head.appendChild(link); 85 | }); 86 | } 87 | 88 | export interface FontPickerProps { 89 | onFontSelect?: (font: GoogleFont) => void; 90 | value?: string; 91 | } 92 | 93 | export const FONT_CATEGORIES = [ 94 | "serif", 95 | "sans-serif", 96 | "display", 97 | "handwriting", 98 | "monospace", 99 | ] as const; 100 | 101 | export type FontCategory = (typeof FONT_CATEGORIES)[number]; 102 | 103 | export const FONT_WEIGHTS = [ 104 | "100", 105 | "200", 106 | "300", 107 | "400", 108 | "500", 109 | "600", 110 | "700", 111 | "800", 112 | "900", 113 | ] as const; 114 | 115 | export type FontWeight = (typeof FONT_WEIGHTS)[number]; 116 | -------------------------------------------------------------------------------- /app/(home)/sections/variants.tsx: -------------------------------------------------------------------------------- 1 | import { FontPicker } from "@/components/ui/font-picker"; 2 | import { useState } from "react"; 3 | 4 | export default function Variants() { 5 | const [font, setFont] = useState(""); 6 | 7 | return ( 8 |
9 |

10 | Variants 11 |

12 |
13 |

14 | The font picker component can be used as different variants. 15 |

16 |
17 |

18 | Default 19 |

20 |
21 | setFont(font)} /> 22 | 26 | This is a custom implementation of the Font Picker component. 27 | 28 |
29 |
30 |
31 |

32 | Custom width 33 |

34 |
35 | setFont(font)} 38 | width={200} 39 | /> 40 |
41 |
42 |
43 |

44 | Custom height 45 |

46 |
47 | setFont(font)} 50 | height={200} 51 | /> 52 |
53 |
54 |
55 |

56 | Without filters 57 |

58 |
59 | setFont(font)} 62 | showFilters={false} 63 | /> 64 |
65 |
66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /content/snippets/fonts.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | file: fonts.ts 3 | filePath: lib/fonts.ts 4 | order: 2 5 | description: Use the fonts component 6 | --- 7 | 8 | ```ts 9 | export interface GoogleFont { 10 | family: string; 11 | variants: string[]; 12 | subsets: string[]; 13 | version: string; 14 | lastModified: string; 15 | files: Record; 16 | category: string; 17 | kind: string; 18 | } 19 | 20 | const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_FONTS_API_KEY; 21 | const API_URL = "https://www.googleapis.com/webfonts/v1/webfonts"; 22 | 23 | // Cache for loaded font stylesheets 24 | const loadedFonts = new Set(); 25 | 26 | // Cache for the Google Fonts API response 27 | let fontsCache: GoogleFont[] | null = null; 28 | let fontsCacheTimestamp: number | null = null; 29 | const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 30 | 31 | export async function fetchGoogleFonts(): Promise { 32 | // Check if we have a valid cache 33 | if ( 34 | fontsCache && 35 | fontsCacheTimestamp && 36 | Date.now() - fontsCacheTimestamp < CACHE_DURATION 37 | ) { 38 | return fontsCache; 39 | } 40 | 41 | if (!API_KEY) { 42 | throw new Error("Google Fonts API key is not configured"); 43 | } 44 | 45 | try { 46 | const response = await fetch(`${API_URL}?key=${API_KEY}&sort=popularity`); 47 | if (!response.ok) { 48 | throw new Error("Failed to fetch Google Fonts"); 49 | } 50 | const data = await response.json(); 51 | fontsCache = data.items; 52 | fontsCacheTimestamp = Date.now(); 53 | return data.items; 54 | } catch (error) { 55 | // If fetch fails and we have a cache, return it even if expired 56 | if (fontsCache) { 57 | return fontsCache; 58 | } 59 | console.error("Error fetching Google Fonts:", error); 60 | throw error; 61 | } 62 | } 63 | 64 | export function getFontUrl(font: GoogleFont, variant = "regular"): string { 65 | const fontFamily = font.family.replace(/\s+/g, "+"); 66 | const fontVariant = variant === "regular" ? "400" : variant; 67 | return `https://fonts.googleapis.com/css2?family=${fontFamily}:wght@${fontVariant}&display=swap`; 68 | } 69 | 70 | export async function loadFont( 71 | fontFamily: string, 72 | variant = "regular" 73 | ): Promise { 74 | if (loadedFonts.has(fontFamily)) { 75 | return; 76 | } 77 | 78 | return new Promise((resolve, reject) => { 79 | const link = document.createElement("link"); 80 | link.href = getFontUrl({ family: fontFamily } as GoogleFont, variant); 81 | link.rel = "stylesheet"; 82 | 83 | link.onload = () => { 84 | loadedFonts.add(fontFamily); 85 | resolve(); 86 | }; 87 | 88 | link.onerror = () => { 89 | reject(new Error(`Failed to load font: ${fontFamily}`)); 90 | }; 91 | 92 | document.head.appendChild(link); 93 | }); 94 | } 95 | 96 | export interface FontPickerProps { 97 | onFontSelect?: (font: GoogleFont) => void; 98 | value?: string; 99 | } 100 | 101 | export const FONT_CATEGORIES = [ 102 | "serif", 103 | "sans-serif", 104 | "display", 105 | "handwriting", 106 | "monospace", 107 | ] as const; 108 | 109 | export type FontCategory = (typeof FONT_CATEGORIES)[number]; 110 | 111 | export const FONT_WEIGHTS = [ 112 | "100", 113 | "200", 114 | "300", 115 | "400", 116 | "500", 117 | "600", 118 | "700", 119 | "800", 120 | "900", 121 | ] as const; 122 | 123 | export type FontWeight = (typeof FONT_WEIGHTS)[number]; 124 | ``` 125 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Dialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function DialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function DialogClose({ 28 | ...props 29 | }: React.ComponentProps) { 30 | return 31 | } 32 | 33 | function DialogOverlay({ 34 | className, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 46 | ) 47 | } 48 | 49 | function DialogContent({ 50 | className, 51 | children, 52 | ...props 53 | }: React.ComponentProps) { 54 | return ( 55 | 56 | 57 | 65 | {children} 66 | 67 | 68 | Close 69 | 70 | 71 | 72 | ) 73 | } 74 | 75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 76 | return ( 77 |
82 | ) 83 | } 84 | 85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 86 | return ( 87 |
95 | ) 96 | } 97 | 98 | function DialogTitle({ 99 | className, 100 | ...props 101 | }: React.ComponentProps) { 102 | return ( 103 | 108 | ) 109 | } 110 | 111 | function DialogDescription({ 112 | className, 113 | ...props 114 | }: React.ComponentProps) { 115 | return ( 116 | 121 | ) 122 | } 123 | 124 | export { 125 | Dialog, 126 | DialogClose, 127 | DialogContent, 128 | DialogDescription, 129 | DialogFooter, 130 | DialogHeader, 131 | DialogOverlay, 132 | DialogPortal, 133 | DialogTitle, 134 | DialogTrigger, 135 | } 136 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | FormProvider, 9 | useFormContext, 10 | useFormState, 11 | type ControllerProps, 12 | type FieldPath, 13 | type FieldValues, 14 | } from "react-hook-form" 15 | 16 | import { cn } from "@/lib/utils" 17 | import { Label } from "@/components/ui/label" 18 | 19 | const Form = FormProvider 20 | 21 | type FormFieldContextValue< 22 | TFieldValues extends FieldValues = FieldValues, 23 | TName extends FieldPath = FieldPath, 24 | > = { 25 | name: TName 26 | } 27 | 28 | const FormFieldContext = React.createContext( 29 | {} as FormFieldContextValue 30 | ) 31 | 32 | const FormField = < 33 | TFieldValues extends FieldValues = FieldValues, 34 | TName extends FieldPath = FieldPath, 35 | >({ 36 | ...props 37 | }: ControllerProps) => { 38 | return ( 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | const useFormField = () => { 46 | const fieldContext = React.useContext(FormFieldContext) 47 | const itemContext = React.useContext(FormItemContext) 48 | const { getFieldState } = useFormContext() 49 | const formState = useFormState({ name: fieldContext.name }) 50 | const fieldState = getFieldState(fieldContext.name, formState) 51 | 52 | if (!fieldContext) { 53 | throw new Error("useFormField should be used within ") 54 | } 55 | 56 | const { id } = itemContext 57 | 58 | return { 59 | id, 60 | name: fieldContext.name, 61 | formItemId: `${id}-form-item`, 62 | formDescriptionId: `${id}-form-item-description`, 63 | formMessageId: `${id}-form-item-message`, 64 | ...fieldState, 65 | } 66 | } 67 | 68 | type FormItemContextValue = { 69 | id: string 70 | } 71 | 72 | const FormItemContext = React.createContext( 73 | {} as FormItemContextValue 74 | ) 75 | 76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
86 | 87 | ) 88 | } 89 | 90 | function FormLabel({ 91 | className, 92 | ...props 93 | }: React.ComponentProps) { 94 | const { error, formItemId } = useFormField() 95 | 96 | return ( 97 |