Shadcn Font Picker
An implementation of a Font Picker component for React, built on top of Shadcn UI's input component and Google Fonts API.
npx shadcn@latest add https://shadcn-font-picker.vercel.app/r/font-picker.json
Setup
Install Shadcn via CLI
Run the shadcn
init command to setup your project:
npx shadcn@latest init
Install necessary Shadcn components:
Run the shadcn
add command to add the necessary shadcn components to your project:
npx shadcn@latest add button command dropdown-menu popover
Install necessary React packages:
yarn add react-window
yarn add -D @types/react-window
Generate a Google Fonts API key:
Go to the Google Fonts API console
Update the .env
file:
NEXT_PUBLIC_GOOGLE_FONTS_API_KEY="your-api-key-here"
To use the font picker component:
Import the font picker component:
import { FontPicker } from "@/components/ui/font-picker";
Use the font picker component:
<FontPicker onChange={(font) => setFont(font)} value={font} />
Copy the code from the snippet below and paste it in your component file.
Snippets
"use client";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { GoogleFont } from "@/lib/fonts";
import { fetchGoogleFonts, loadFont } from "@/lib/fonts";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, Filter } from "lucide-react";
import * as React from "react";
import { ComponentType } from "react";
import { FixedSizeList as _FixedSizeList, FixedSizeListProps } from "react-window";
const FixedSizeList = _FixedSizeList as ComponentType<FixedSizeListProps>;
function FontListItem({
font,
isSelected,
onSelect,
}: {
font: GoogleFont;
isSelected: boolean;
onSelect: () => void;
}) {
const [isFontLoaded, setIsFontLoaded] = React.useState(false);
React.useEffect(() => {
if (!isFontLoaded) {
loadFont(font.family)
.then(() => setIsFontLoaded(true))
.catch((error) => console.error("Failed to load font:", error));
}
}, [isFontLoaded, font.family]);
return (
<CommandItem
value={font.family}
onSelect={onSelect}
className="data-[selected=true]:bg-accent flex cursor-pointer items-center gap-2 p-2"
data-selected={isSelected}
>
<Check
className={cn(
"h-3 w-3 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{font.family}</span>
<span
className={cn(
"text-muted-foreground text-xs transition-opacity duration-300",
isFontLoaded ? "opacity-100" : "opacity-0",
)}
style={{
fontFamily: isFontLoaded ? font.family : "system-ui",
}}
>
The quick brown fox
</span>
</div>
</CommandItem>
);
}
interface FontPickerProps {
onChange?: (font: GoogleFont["family"]) => void;
value?: string;
width?: number;
height?: number;
className?: string;
showFilters?: boolean;
}
export function FontPicker({
onChange,
value,
width = 300,
height = 300,
className,
showFilters = true,
}: FontPickerProps) {
const [selectedFont, setSelectedFont] = React.useState<GoogleFont | null>(
null,
);
const [search, setSearch] = React.useState("");
const [isOpen, setIsOpen] = React.useState(false);
const [selectedCategory, setSelectedCategory] = React.useState<string>("all");
const [fonts, setFonts] = React.useState<GoogleFont[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
const loadFonts = async () => {
try {
setIsLoading(true);
const fetchedFonts = await fetchGoogleFonts();
setFonts(fetchedFonts);
const font = fetchedFonts.find((font) => font.family === value);
if (font) {
setSelectedFont(font);
}
setError(null);
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to load fonts"),
);
console.error("Error loading fonts:", err);
} finally {
setIsLoading(false);
}
};
loadFonts();
}, [value]);
const categories = React.useMemo(() => {
const uniqueCategories = new Set(fonts.map((font) => font.category));
return Array.from(uniqueCategories).sort();
}, [fonts]);
const filteredFonts = React.useMemo(() => {
return fonts.filter((font: GoogleFont) => {
const matchesSearch = font.family
.toLowerCase()
.includes(search.toLowerCase());
const matchesCategory =
!showFilters ||
selectedCategory === "all" ||
font.category === selectedCategory;
return matchesSearch && matchesCategory;
});
}, [fonts, search, selectedCategory, showFilters]);
const handleSelectFont = React.useCallback(
(font: GoogleFont) => {
setSelectedFont(font);
onChange?.(font.family);
setIsOpen(false);
},
[onChange],
);
const handleOpenChange = React.useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const Row = React.useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const font = filteredFonts[index];
return (
<div style={style}>
<FontListItem
font={font}
isSelected={selectedFont?.family === font.family}
onSelect={() => handleSelectFont(font)}
/>
</div>
);
},
[filteredFonts, selectedFont, handleSelectFont],
);
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
ref={buttonRef}
variant="outline"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Select font"
className={cn("group relative justify-between", className)}
style={{ width }}
>
<span className="truncate">
{selectedFont
? filteredFonts.find(
(font) => font.family === selectedFont.family,
)?.family
: "Select font..."}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width, height }} align="start">
<Command>
<CommandInput
placeholder="Search fonts..."
value={search}
onValueChange={setSearch}
className="border-none focus:ring-0"
/>
<div className="flex items-center justify-between gap-2 border-b px-3 py-1">
{showFilters && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="hover:bg-accent flex h-8 items-center gap-2 px-2"
>
<Filter className="text-muted-foreground h-4 w-4" />
<span className="text-sm capitalize">
{selectedCategory === "all"
? "All Categories"
: selectedCategory}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[200px]">
<DropdownMenuRadioGroup
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<DropdownMenuRadioItem value="all">
All Categories
</DropdownMenuRadioItem>
{categories.map((category) => (
<DropdownMenuRadioItem
key={category}
value={category}
className="capitalize"
>
{category}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<span className="text-muted-foreground text-xs">
{filteredFonts.length} fonts
</span>
</div>
{isLoading ? (
<div className="flex items-center justify-center p-4">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-900" />
</div>
) : error ? (
<div className="flex items-center justify-center p-4 text-sm text-red-500">
Failed to load fonts. Please try again later.
</div>
) : (
<>
<CommandEmpty>No fonts found.</CommandEmpty>
<CommandGroup>
<div className={`h-[${height}px]`}>
<FixedSizeList
height={height}
itemCount={filteredFonts.length}
itemSize={55}
width="100%"
>
{Row}
</FixedSizeList>
</div>
</CommandGroup>
</>
)}
</Command>
</PopoverContent>
</Popover>
);
}
Variants
The font picker component can be used as different variants.
Default
This is a custom implementation of the Font Picker component.