|
| 1 | +--- |
| 2 | +title: Component Variants |
| 3 | +description: Build type-safe components with variant-based styling using tw |
| 4 | +--- |
| 5 | + |
| 6 | +When building design systems, components often need multiple variants (e.g., primary/secondary buttons, small/medium/large sizes). The `tw` template tag combined with TypeScript provides a powerful pattern for creating type-safe, variant-based components. |
| 7 | + |
| 8 | +## Basic Variant Pattern |
| 9 | + |
| 10 | +Define variants as a record mapping variant names to `TwStyle` objects: |
| 11 | + |
| 12 | +```tsx |
| 13 | +import { View, Text, type ViewStyle, type TextStyle } from "react-native"; |
| 14 | +import { Pressable, tw, type TwStyle } from "@mgcrea/react-native-tailwind"; |
| 15 | + |
| 16 | +// Define variant types |
| 17 | +type ButtonVariant = "solid" | "outline" | "ghost"; |
| 18 | + |
| 19 | +// Define styles for each variant |
| 20 | +const variantStyles = { |
| 21 | + solid: { |
| 22 | + container: tw`bg-blue-500 active:bg-blue-700`, |
| 23 | + text: tw`text-white`, |
| 24 | + }, |
| 25 | + outline: { |
| 26 | + container: tw`border border-blue-500 bg-white active:bg-blue-50`, |
| 27 | + text: tw`text-blue-500`, |
| 28 | + }, |
| 29 | + ghost: { |
| 30 | + container: tw`active:bg-blue-50`, |
| 31 | + text: tw`text-blue-500`, |
| 32 | + }, |
| 33 | +} satisfies Record<ButtonVariant, { container: TwStyle<ViewStyle>; text: TwStyle<TextStyle> }>; |
| 34 | + |
| 35 | +type ButtonProps = { |
| 36 | + title: string; |
| 37 | + variant?: ButtonVariant; |
| 38 | + onPress?: () => void; |
| 39 | +}; |
| 40 | + |
| 41 | +export function Button({ title, variant = "solid", onPress }: ButtonProps) { |
| 42 | + const styles = variantStyles[variant]; |
| 43 | + |
| 44 | + return ( |
| 45 | + <Pressable |
| 46 | + className="px-6 py-3 rounded-lg items-center justify-center" |
| 47 | + style={(state) => [styles.container.style, state.pressed && styles.container.activeStyle]} |
| 48 | + onPress={onPress} |
| 49 | + > |
| 50 | + <Text style={styles.text.style}>{title}</Text> |
| 51 | + </Pressable> |
| 52 | + ); |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +## Multi-Dimensional Variants |
| 57 | + |
| 58 | +Components often have multiple variant dimensions (e.g., size AND color). Structure your variants accordingly: |
| 59 | + |
| 60 | +```tsx |
| 61 | +import { View, Text, type ViewStyle, type TextStyle } from "react-native"; |
| 62 | +import { Pressable, tw, type TwStyle } from "@mgcrea/react-native-tailwind"; |
| 63 | + |
| 64 | +// Size variants |
| 65 | +type ButtonSize = "sm" | "md" | "lg"; |
| 66 | + |
| 67 | +const sizeVariants = { |
| 68 | + sm: { |
| 69 | + container: tw`h-9 px-3 rounded-lg`, |
| 70 | + text: tw`text-sm`, |
| 71 | + }, |
| 72 | + md: { |
| 73 | + container: tw`h-11 px-5 rounded-xl`, |
| 74 | + text: tw`text-base`, |
| 75 | + }, |
| 76 | + lg: { |
| 77 | + container: tw`h-14 px-8 rounded-2xl`, |
| 78 | + text: tw`text-lg`, |
| 79 | + }, |
| 80 | +} satisfies Record<ButtonSize, { container: TwStyle<ViewStyle>; text: TwStyle<TextStyle> }>; |
| 81 | + |
| 82 | +// Color variants |
| 83 | +type ButtonColor = "primary" | "secondary" | "destructive"; |
| 84 | + |
| 85 | +const colorVariants = { |
| 86 | + primary: { |
| 87 | + container: tw`bg-blue-500 active:bg-blue-700`, |
| 88 | + text: tw`text-white`, |
| 89 | + }, |
| 90 | + secondary: { |
| 91 | + container: tw`bg-gray-500 active:bg-gray-700`, |
| 92 | + text: tw`text-white`, |
| 93 | + }, |
| 94 | + destructive: { |
| 95 | + container: tw`bg-red-500 active:bg-red-700`, |
| 96 | + text: tw`text-white`, |
| 97 | + }, |
| 98 | +} satisfies Record<ButtonColor, { container: TwStyle<ViewStyle>; text: TwStyle<TextStyle> }>; |
| 99 | + |
| 100 | +type ButtonProps = { |
| 101 | + title: string; |
| 102 | + size?: ButtonSize; |
| 103 | + color?: ButtonColor; |
| 104 | + onPress?: () => void; |
| 105 | +}; |
| 106 | + |
| 107 | +export function Button({ title, size = "md", color = "primary", onPress }: ButtonProps) { |
| 108 | + const sizeStyles = sizeVariants[size]; |
| 109 | + const colorStyles = colorVariants[color]; |
| 110 | + |
| 111 | + return ( |
| 112 | + <Pressable |
| 113 | + className="flex-row items-center justify-center" |
| 114 | + style={(state) => [ |
| 115 | + sizeStyles.container.style, |
| 116 | + colorStyles.container.style, |
| 117 | + state.pressed && colorStyles.container.activeStyle, |
| 118 | + ]} |
| 119 | + onPress={onPress} |
| 120 | + > |
| 121 | + <Text className="font-semibold" style={[sizeStyles.text.style, colorStyles.text.style]}> |
| 122 | + {title} |
| 123 | + </Text> |
| 124 | + </Pressable> |
| 125 | + ); |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +## Complete Example: Button with Variants |
| 130 | + |
| 131 | +Here's a full implementation combining size, color, and style variants: |
| 132 | + |
| 133 | +```tsx |
| 134 | +import { type PropsWithChildren } from "react"; |
| 135 | +import { Text, type StyleProp, type ViewStyle, type TextStyle } from "react-native"; |
| 136 | +import { Pressable, tw, type PressableProps, type TwStyle } from "@mgcrea/react-native-tailwind"; |
| 137 | + |
| 138 | +// Types |
| 139 | +export type ButtonSize = "sm" | "md" | "lg"; |
| 140 | +export type ButtonVariant = "solid" | "outline" | "ghost"; |
| 141 | +export type ButtonColor = "primary" | "destructive" | "neutral"; |
| 142 | + |
| 143 | +// Size variants |
| 144 | +const sizeVariants = { |
| 145 | + sm: { |
| 146 | + container: tw`h-9 px-3 rounded-lg`, |
| 147 | + text: tw`text-sm`, |
| 148 | + }, |
| 149 | + md: { |
| 150 | + container: tw`h-11 px-5 rounded-xl`, |
| 151 | + text: tw`text-base`, |
| 152 | + }, |
| 153 | + lg: { |
| 154 | + container: tw`h-14 px-8 rounded-2xl`, |
| 155 | + text: tw`text-lg`, |
| 156 | + }, |
| 157 | +} satisfies Record<ButtonSize, { container: TwStyle<ViewStyle>; text: TwStyle<TextStyle> }>; |
| 158 | + |
| 159 | +// Style variants per color |
| 160 | +const variantStyles: Record< |
| 161 | + ButtonVariant, |
| 162 | + Record<ButtonColor, { container: TwStyle<ViewStyle>; text: TwStyle<TextStyle> }> |
| 163 | +> = { |
| 164 | + solid: { |
| 165 | + primary: { |
| 166 | + container: tw`bg-blue-500 active:bg-blue-700 disabled:bg-blue-300`, |
| 167 | + text: tw`text-white`, |
| 168 | + }, |
| 169 | + destructive: { |
| 170 | + container: tw`bg-red-500 active:bg-red-700 disabled:bg-red-300`, |
| 171 | + text: tw`text-white`, |
| 172 | + }, |
| 173 | + neutral: { |
| 174 | + container: tw`bg-gray-900 active:bg-gray-700 disabled:bg-gray-400`, |
| 175 | + text: tw`text-white`, |
| 176 | + }, |
| 177 | + }, |
| 178 | + outline: { |
| 179 | + primary: { |
| 180 | + container: tw`border border-blue-500 bg-white active:bg-blue-50`, |
| 181 | + text: tw`text-blue-500`, |
| 182 | + }, |
| 183 | + destructive: { |
| 184 | + container: tw`border border-red-500 bg-white active:bg-red-50`, |
| 185 | + text: tw`text-red-500`, |
| 186 | + }, |
| 187 | + neutral: { |
| 188 | + container: tw`border border-gray-300 bg-white active:bg-gray-100`, |
| 189 | + text: tw`text-gray-900`, |
| 190 | + }, |
| 191 | + }, |
| 192 | + ghost: { |
| 193 | + primary: { |
| 194 | + container: tw`active:bg-blue-50`, |
| 195 | + text: tw`text-blue-500`, |
| 196 | + }, |
| 197 | + destructive: { |
| 198 | + container: tw`active:bg-red-50`, |
| 199 | + text: tw`text-red-500`, |
| 200 | + }, |
| 201 | + neutral: { |
| 202 | + container: tw`active:bg-gray-100`, |
| 203 | + text: tw`text-gray-900`, |
| 204 | + }, |
| 205 | + }, |
| 206 | +}; |
| 207 | + |
| 208 | +// Props |
| 209 | +export type ButtonProps = Omit<PressableProps, "children" | "style"> & { |
| 210 | + title?: string; |
| 211 | + variant?: ButtonVariant; |
| 212 | + size?: ButtonSize; |
| 213 | + color?: ButtonColor; |
| 214 | + style?: StyleProp<ViewStyle>; |
| 215 | + textStyle?: StyleProp<TextStyle>; |
| 216 | +}; |
| 217 | + |
| 218 | +// Component |
| 219 | +export function Button({ |
| 220 | + variant = "solid", |
| 221 | + size = "md", |
| 222 | + color = "primary", |
| 223 | + title, |
| 224 | + children, |
| 225 | + disabled, |
| 226 | + style, |
| 227 | + textStyle, |
| 228 | + ...props |
| 229 | +}: PropsWithChildren<ButtonProps>) { |
| 230 | + const sizeStyles = sizeVariants[size]; |
| 231 | + const colorStyles = variantStyles[variant][color]; |
| 232 | + |
| 233 | + return ( |
| 234 | + <Pressable |
| 235 | + className="flex-row items-center justify-center" |
| 236 | + style={(state) => [ |
| 237 | + sizeStyles.container.style, |
| 238 | + colorStyles.container.style, |
| 239 | + state.pressed && colorStyles.container.activeStyle, |
| 240 | + disabled && colorStyles.container.disabledStyle, |
| 241 | + style, |
| 242 | + ]} |
| 243 | + disabled={disabled} |
| 244 | + {...props} |
| 245 | + > |
| 246 | + {children ?? ( |
| 247 | + <Text |
| 248 | + className="font-semibold" |
| 249 | + style={[sizeStyles.text.style, colorStyles.text.style, textStyle]} |
| 250 | + > |
| 251 | + {title} |
| 252 | + </Text> |
| 253 | + )} |
| 254 | + </Pressable> |
| 255 | + ); |
| 256 | +} |
| 257 | +``` |
| 258 | + |
| 259 | +### Usage |
| 260 | + |
| 261 | +```tsx |
| 262 | +// Default: solid, medium, primary |
| 263 | +<Button title="Click Me" onPress={handlePress} /> |
| 264 | + |
| 265 | +// Outline destructive button |
| 266 | +<Button |
| 267 | + title="Delete" |
| 268 | + variant="outline" |
| 269 | + color="destructive" |
| 270 | + onPress={handleDelete} |
| 271 | +/> |
| 272 | + |
| 273 | +// Large ghost button |
| 274 | +<Button |
| 275 | + title="Learn More" |
| 276 | + variant="ghost" |
| 277 | + size="lg" |
| 278 | + color="primary" |
| 279 | + onPress={handleLearnMore} |
| 280 | +/> |
| 281 | + |
| 282 | +// Disabled state |
| 283 | +<Button |
| 284 | + title="Submitting..." |
| 285 | + disabled={isLoading} |
| 286 | +/> |
| 287 | + |
| 288 | +// With custom style overrides |
| 289 | +<Button |
| 290 | + title="Custom" |
| 291 | + style={{ marginTop: 16 }} |
| 292 | + textStyle={{ letterSpacing: 1 }} |
| 293 | +/> |
| 294 | +``` |
| 295 | + |
| 296 | +## Key Points |
| 297 | + |
| 298 | +### Type Safety with `satisfies` |
| 299 | + |
| 300 | +Use TypeScript's `satisfies` operator to ensure your variant objects match the expected shape while preserving literal types: |
| 301 | + |
| 302 | +```tsx |
| 303 | +const sizeVariants = { |
| 304 | + sm: { container: tw`h-9 px-3`, text: tw`text-sm` }, |
| 305 | + md: { container: tw`h-11 px-5`, text: tw`text-base` }, |
| 306 | + lg: { container: tw`h-14 px-8`, text: tw`text-lg` }, |
| 307 | +} satisfies Record<ButtonSize, { container: TwStyle<ViewStyle>; text: TwStyle<TextStyle> }>; |
| 308 | +``` |
| 309 | + |
| 310 | +### Using TwStyle Properties |
| 311 | + |
| 312 | +The `TwStyle` type provides typed access to modifier styles: |
| 313 | + |
| 314 | +```tsx |
| 315 | +type TwStyle<T> = { |
| 316 | + style: T; // Base styles |
| 317 | + activeStyle?: T; // active: modifier |
| 318 | + focusStyle?: T; // focus: modifier |
| 319 | + disabledStyle?: T; // disabled: modifier |
| 320 | + hoverStyle?: T; // hover: modifier |
| 321 | + // ... other modifiers |
| 322 | +}; |
| 323 | +``` |
| 324 | + |
| 325 | +Apply them conditionally based on component state: |
| 326 | + |
| 327 | +```tsx |
| 328 | +<Pressable |
| 329 | + style={(state) => [ |
| 330 | + styles.container.style, |
| 331 | + state.pressed && styles.container.activeStyle, |
| 332 | + state.focused && styles.container.focusStyle, |
| 333 | + disabled && styles.container.disabledStyle, |
| 334 | + ]} |
| 335 | +> |
| 336 | +``` |
| 337 | + |
| 338 | +### Combining Multiple Style Sources |
| 339 | + |
| 340 | +When combining styles from different variant dimensions, use array syntax: |
| 341 | + |
| 342 | +```tsx |
| 343 | +style={(state) => [ |
| 344 | + sizeStyles.container.style, // Size dimension |
| 345 | + colorStyles.container.style, // Color dimension |
| 346 | + state.pressed && colorStyles.container.activeStyle, |
| 347 | + style, // User overrides |
| 348 | +]} |
| 349 | +``` |
| 350 | + |
| 351 | +### Null-Safe Variants |
| 352 | + |
| 353 | +For optional variant styles, use null checks: |
| 354 | + |
| 355 | +```tsx |
| 356 | +const variantStyles = { |
| 357 | + link: { |
| 358 | + container: null, // No container styles for link variant |
| 359 | + text: tw`text-blue-500 underline`, |
| 360 | + }, |
| 361 | +}; |
| 362 | + |
| 363 | +// In component |
| 364 | +style={[ |
| 365 | + sizeStyles.container.style, |
| 366 | + colorStyles.container?.style, // Safe access |
| 367 | + state.pressed && colorStyles.container?.activeStyle, |
| 368 | +]} |
| 369 | +``` |
| 370 | + |
| 371 | +## What's Next? |
| 372 | + |
| 373 | +- Learn about [Compile-Time tw](/react-native-tailwind/guides/compile-time-tw/) for the `tw` template basics |
| 374 | +- Explore [State Modifiers](/react-native-tailwind/guides/state-modifiers/) for interactive styling |
| 375 | +- Check out [Reusable Components](/react-native-tailwind/guides/reusable-components/) for component library patterns |
0 commit comments