Shadcn Component Wrappers
Overview
Shadcn UI provides an excellent foundation for building design systems, but directly importing and composing individual components throughout your application can lead to verbose, repetitive code. This guide demonstrates a wrapper-based approach that simplifies component usage while maintaining the flexibility to use native Shadcn components when needed for complex UI scenarios.
Key Benefits
- Reduced Boilerplate: Eliminate repetitive component composition patterns
- Type Safety: Built-in TypeScript support for common value types
- Consistent API: Standardized interface across similar components
- Flexibility: Preserve access to native Shadcn components for advanced use cases
Prerequest
- Shadcn base setup
- add following to your
components.jsonatregistriesblock
{
"registries": {
"@glrk-ui": "https://ui.glrk.dev/registry/{name}.json"
}
}Type Definitions
Core Types
The wrapper system supports three primitive value types that cover the majority of form and selection use cases:
type readOnlyChildren = Readonly<{
children: React.ReactNode;
}>
type allowedPrimitiveT = string | number | boolean
type optionT = {
label: React.ReactNode
value: allowedPrimitiveT
className?: string
}
type groupT = {
group: string
options: (allowedPrimitiveT | optionT)[]
className?: string
}
type optionsT = (allowedPrimitiveT | optionT | groupT)[]
type indicatorAtT = "right" | "left"Design Rationale
Primitive Type Restriction: The system intentionally limits allowed values to string, number, and boolean. These types handle the vast majority of form scenarios.
Empty Values: Use empty strings ("") for empty values, or omit the property entirely. Extend the type system as needed for your specific requirements.
Utility Functions
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function isAllowedPrimitive(value: unknown): value is allowedPrimitiveT {
return ["string", "number", "boolean"].includes(typeof value)
}
export function parseAllowedPrimitive(value: allowedPrimitiveT): allowedPrimitiveT {
if (typeof value !== "string") return value
const trimmed = value.trim()
if (trimmed === "true") return true
if (trimmed === "false") return false
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
return trimmed
}
export function optionTypeChecker<T>(key: keyof T) {
return (option: any): option is T => !!option && typeof option === "object" && key in option
}
export const isSeparator = (item: any) => item === "---"
export const isOption = optionTypeChecker<optionT>("value")
export const isGroup = optionTypeChecker<groupT>("group")
export const getValue = (item: allowedPrimitiveT | optionT) => typeof item === "object" ? item.value : item
export const getLabel = (item: allowedPrimitiveT | optionT) => typeof item === "object" ? item.label : `${item}`
export function getKey(item: allowedPrimitiveT | optionT, i: number): string {
const val = getValue(item)
if (typeof val === "boolean") return `key-${val}`
if (val === "---") return `${i}`
return `${val}`
}Options Configuration
Flexible Option Formats
The wrapper system supports multiple option formats to accommodate different use cases:
const options: optionsT = [
// Simple primitive values (label equals value)
"Data 1",
false,
12,
// Visual separator
"---",
// Object format with custom label
{
label: "Obj 1",
value: "obj-1",
},
// Object with custom styling
{
label: "Obj 2",
value: "obj-2",
className: "bg-red-50",
},
// Rich content with React nodes
{
value: "apple",
label: <><Apple className="mr-2" /> Apple</>
},
"---",
// Grouped options
{
group: "Group 1",
options: [
"grp 1",
21,
true,
{ value: "banana", label: <><Banana className="mr-2" /> Banana</> }
],
},
// Grouped options with styling
{
group: "Group 2",
options: ["grp 2", 22],
className: "bg-amber-50",
},
]Option Format Guidelines
- Primitive values: Use directly when the display label matches the value
- Separators: Use
"---"string for visual separation between items - Object format: Use when you need custom labels, styling, or rich content
- Grouped options: Organize related options under labeled groups
- Custom styling: Apply Tailwind classes via
classNamefor visual distinction
Value Conversion Pattern
Understanding String Conversion
Shadcn's controlled components use string values internally. The wrapper system handles conversion between strings and primitive types transparently. Some components directly supports type conversion. If the component not supported this feature, you can use following guide.
Implementation Patterns
// ❌ Incorrect: Type mismatch
const [value, setValue] = useState(true)
<SelectWrapper
value={value}
onValueChange={setValue}
/>
// ✅ Correct: Convert to string
const [value, setValue] = useState("true")
<SelectWrapper
value={value}
onValueChange={setValue}
/>
// ✅ Recommended: Preserve primitive types
const [value, setValue] = useState<allowedPrimitiveT>(true)
<SelectWrapper
value={`${value}`}
onValueChange={v => setValue(parseAllowedPrimitive(v))}
/>Best Practices
- String State: Simplest approach for string-only values
- Primitive State with Conversion: Use
parseAllowedPrimitiveto maintain type fidelity - Template Literals: Use
`${value}`for type-safe string conversion - Type Annotations: Explicitly type state as
allowedPrimitiveTwhen using conversion
Next Steps
With this foundation in place, you can create wrapper components for common Shadcn UI elements. Checkout custom wrappers.