Gluestack UI v4 - Creating Component Variants
This sub-skill focuses on creating custom variants for existing gluestack-ui v4 components, allowing you to extend the design system with project-specific styling patterns while maintaining consistency and type safety.
When to Create a Variant
Create a new variant when:
- Repeating the same style combination - Multiple places use the same className pattern
- Project-specific design patterns - Brand-specific button styles, card types, etc.
- Conditional styling - Component appearance changes based on props
- Extending existing components - Adding new visual styles to Gluestack components
- Theme-specific variations - Different appearances for specific contexts
Don't create variants for:
- One-off custom styles (use className instead)
- Simple modifications (use existing props + className)
- Styles that should be in the global design system
Variant Creation Workflow
Step 1: Analyze the Component
Before creating a variant, understand:
- What's the base component? - Button, Card, Badge, etc.
- What visual states are needed? - Colors, sizes, borders, shadows
- Are there sub-components? - ButtonText, CardHeader, etc.
- What props should control variants? - variant, size, state props
- Should variants affect children? - Parent variants for sub-components
Step 2: Plan Variant Structure
Define your variant system:
tsx
1// Example: Planning a Badge component with variants
2{
3 variant: ['default', 'success', 'warning', 'error', 'info']
4 size: ['sm', 'md', 'lg']
5 shape: ['rounded', 'pill', 'square']
6}
Step 3: Implement with tva
Use tva (Tailwind Variant Authority) to create type-safe, composable variants.
Creating Simple Variants
Template: Adding Variants to a Custom Component
tsx
1import React from 'react';
2import { tva } from '@gluestack-ui/utils/nativewind-utils';
3import { Box } from '@/components/ui/box';
4import { Text } from '@/components/ui/text';
5
6interface BadgeProps {
7 readonly variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
8 readonly size?: 'sm' | 'md' | 'lg';
9 readonly shape?: 'rounded' | 'pill' | 'square';
10 readonly className?: string;
11 readonly children: React.ReactNode;
12}
13
14// Define variant styles
15const badgeStyles = tva({
16 base: 'inline-flex items-center justify-center font-medium',
17 variants: {
18 variant: {
19 default: 'bg-muted text-muted-foreground',
20 success: 'bg-primary/10 text-primary border border-primary/20',
21 warning: 'bg-accent/10 text-accent-foreground border border-accent/20',
22 error: 'bg-destructive/10 text-destructive border border-destructive/20',
23 info: 'bg-secondary/10 text-secondary-foreground border border-secondary/20',
24 },
25 size: {
26 sm: 'px-2 py-0.5 text-xs',
27 md: 'px-3 py-1 text-sm',
28 lg: 'px-4 py-1.5 text-base',
29 },
30 shape: {
31 rounded: 'rounded-md',
32 pill: 'rounded-full',
33 square: 'rounded-none',
34 },
35 },
36 defaultVariants: {
37 variant: 'default',
38 size: 'md',
39 shape: 'rounded',
40 },
41});
42
43export const Badge = ({
44 variant,
45 size,
46 shape,
47 className,
48 children
49}: BadgeProps) => {
50 return (
51 <Box className={badgeStyles({ variant, size, shape, class: className })}>
52 <Text>{children}</Text>
53 </Box>
54 );
55};
56
57// Usage:
58// <Badge variant="success" size="lg" shape="pill">Active</Badge>
59// <Badge variant="error" size="sm">Error</Badge>
Key Points:
- ✅ Uses
tva for variant management
- ✅ Base styles apply to all variants
- ✅ Multiple variant dimensions (variant, size, shape)
- ✅ Default variants specified
- ✅ className override support with
class parameter
- ✅ TypeScript types for variant options
Extending Existing Gluestack Components
tsx
1import React from 'react';
2import { tva } from '@gluestack-ui/utils/nativewind-utils';
3import { Button as GluestackButton, ButtonText } from '@/components/ui/button';
4
5// Define additional variant styles
6const customButtonStyles = tva({
7 base: '',
8 variants: {
9 variant: {
10 // Extend existing variants with new ones
11 gradient: 'bg-gradient-to-r from-primary to-accent',
12 glass: 'bg-background/20 backdrop-blur-lg border border-border/50',
13 neon: 'bg-transparent border-2 border-primary shadow-[0_0_15px_rgba(59,130,246,0.5)]',
14 },
15 size: {
16 // Add custom sizes
17 xs: 'px-2 py-1',
18 xl: 'px-8 py-4',
19 },
20 },
21});
22
23interface CustomButtonProps {
24 readonly variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' | 'gradient' | 'glass' | 'neon';
25 readonly size?: 'default' | 'sm' | 'lg' | 'icon' | 'xs' | 'xl';
26 readonly className?: string;
27 readonly onPress?: () => void;
28 readonly isDisabled?: boolean;
29 readonly children: React.ReactNode;
30}
31
32export const CustomButton = ({
33 variant = 'default',
34 size = 'default',
35 className,
36 onPress,
37 isDisabled,
38 children,
39}: CustomButtonProps) => {
40 // Use Gluestack Button for built-in variants
41 if (['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'].includes(variant)) {
42 return (
43 <GluestackButton
44 variant={variant as any}
45 size={['default', 'sm', 'lg', 'icon'].includes(size) ? size as any : 'default'}
46 onPress={onPress}
47 isDisabled={isDisabled}
48 className={className}
49 >
50 {children}
51 </GluestackButton>
52 );
53 }
54
55 // Use custom variants
56 return (
57 <GluestackButton
58 onPress={onPress}
59 isDisabled={isDisabled}
60 className={customButtonStyles({ variant: variant as any, size: size as any, class: className })}
61 >
62 {children}
63 </GluestackButton>
64 );
65};
66
67// Usage:
68// <CustomButton variant="gradient" size="xl">
69// <ButtonText>Gradient Button</ButtonText>
70// </CustomButton>
71//
72// <CustomButton variant="neon" size="lg">
73// <ButtonText>Neon Button</ButtonText>
74// </CustomButton>
Key Points:
- ✅ Extends existing component
- ✅ Preserves original variants
- ✅ Adds new custom variants
- ✅ Maintains compound component pattern
- ✅ Type-safe variant options
Parent-Child Variant Relationships
When creating components with sub-components, use parentVariants to style children based on parent state.
Template: Card with Variant-Aware Children
tsx
1import React from 'react';
2import { tva } from '@gluestack-ui/utils/nativewind-utils';
3import { Box } from '@/components/ui/box';
4import { Heading } from '@/components/ui/heading';
5import { Text } from '@/components/ui/text';
6
7interface CardProps {
8 readonly variant?: 'default' | 'elevated' | 'outlined' | 'ghost';
9 readonly colorScheme?: 'neutral' | 'primary' | 'success' | 'error';
10 readonly className?: string;
11 readonly children: React.ReactNode;
12}
13
14interface CardHeaderProps {
15 readonly className?: string;
16 readonly children: React.ReactNode;
17}
18
19interface CardBodyProps {
20 readonly className?: string;
21 readonly children: React.ReactNode;
22}
23
24// Parent card styles
25const cardStyles = tva({
26 base: 'rounded-lg overflow-hidden',
27 variants: {
28 variant: {
29 default: 'border border-border bg-card',
30 elevated: 'shadow-hard-2 bg-card',
31 outlined: 'border-2 border-border bg-transparent',
32 ghost: 'bg-transparent',
33 },
34 colorScheme: {
35 neutral: '',
36 primary: 'border-primary/20',
37 success: 'border-primary/20',
38 error: 'border-destructive/20',
39 },
40 },
41 compoundVariants: [
42 {
43 variant: 'default',
44 colorScheme: 'primary',
45 class: 'bg-primary/5',
46 },
47 {
48 variant: 'default',
49 colorScheme: 'success',
50 class: 'bg-primary/5',
51 },
52 {
53 variant: 'default',
54 colorScheme: 'error',
55 class: 'bg-destructive/5',
56 },
57 ],
58 defaultVariants: {
59 variant: 'default',
60 colorScheme: 'neutral',
61 },
62});
63
64// Child styles that respond to parent variants
65const cardHeaderStyles = tva({
66 base: 'p-4 border-b',
67 parentVariants: {
68 variant: {
69 default: 'border-border',
70 elevated: 'border-border/50',
71 outlined: 'border-border',
72 ghost: 'border-transparent',
73 },
74 colorScheme: {
75 neutral: '',
76 primary: 'border-primary/20 bg-primary/5',
77 success: 'border-primary/20 bg-primary/5',
78 error: 'border-destructive/20 bg-destructive/5',
79 },
80 },
81});
82
83const cardBodyStyles = tva({
84 base: 'p-4',
85 parentVariants: {
86 colorScheme: {
87 neutral: '',
88 primary: '',
89 success: '',
90 error: '',
91 },
92 },
93});
94
95// Context to share variant state with children
96const CardContext = React.createContext<Pick<CardProps, 'variant' | 'colorScheme'>>({
97 variant: 'default',
98 colorScheme: 'neutral',
99});
100
101export const Card = ({
102 variant = 'default',
103 colorScheme = 'neutral',
104 className,
105 children
106}: CardProps) => {
107 return (
108 <CardContext.Provider value={{ variant, colorScheme }}>
109 <Box className={cardStyles({ variant, colorScheme, class: className })}>
110 {children}
111 </Box>
112 </CardContext.Provider>
113 );
114};
115
116export const CardHeader = ({ className, children }: CardHeaderProps) => {
117 const { variant, colorScheme } = React.useContext(CardContext);
118 return (
119 <Box className={cardHeaderStyles({ parentVariants: { variant, colorScheme }, class: className })}>
120 {children}
121 </Box>
122 );
123};
124
125export const CardBody = ({ className, children }: CardBodyProps) => {
126 const { variant, colorScheme } = React.useContext(CardContext);
127 return (
128 <Box className={cardBodyStyles({ parentVariants: { colorScheme }, class: className })}>
129 {children}
130 </Box>
131 );
132};
133
134// Usage:
135// <Card variant="elevated" colorScheme="primary">
136// <CardHeader>
137// <Heading size="lg">Success Card</Heading>
138// </CardHeader>
139// <CardBody>
140// <Text>This card responds to parent variants</Text>
141// </CardBody>
142// </Card>
Key Points:
- ✅ Parent context shares variant state
- ✅ Children use
parentVariants to style based on parent
- ✅ Compound variants for complex combinations
- ✅ Type-safe context usage
- ✅ Flexible composition
Compound Variants
Use compound variants when combinations of variant options need special styling.
tsx
1import React from 'react';
2import { tva } from '@gluestack-ui/utils/nativewind-utils';
3import { Button, ButtonText, ButtonIcon } from '@/components/ui/button';
4import { Loader2Icon } from '@/components/ui/icon';
5
6interface ActionButtonProps {
7 readonly variant?: 'solid' | 'outline' | 'ghost';
8 readonly colorScheme?: 'primary' | 'secondary' | 'destructive';
9 readonly size?: 'sm' | 'md' | 'lg';
10 readonly isLoading?: boolean;
11 readonly isDisabled?: boolean;
12 readonly className?: string;
13 readonly onPress?: () => void;
14 readonly children: React.ReactNode;
15}
16
17const actionButtonStyles = tva({
18 base: 'rounded-md font-medium transition-colors',
19 variants: {
20 variant: {
21 solid: '',
22 outline: 'border-2 bg-transparent',
23 ghost: 'bg-transparent',
24 },
25 colorScheme: {
26 primary: '',
27 secondary: '',
28 destructive: '',
29 },
30 size: {
31 sm: 'px-3 py-1.5 text-sm',
32 md: 'px-4 py-2 text-base',
33 lg: 'px-6 py-3 text-lg',
34 },
35 },
36 compoundVariants: [
37 // Solid + Primary
38 {
39 variant: 'solid',
40 colorScheme: 'primary',
41 class: 'bg-primary text-primary-foreground data-[hover=true]:bg-primary/90',
42 },
43 // Solid + Destructive
44 {
45 variant: 'solid',
46 colorScheme: 'destructive',
47 class: 'bg-destructive text-white data-[hover=true]:bg-destructive/90',
48 },
49 // Outline + Primary
50 {
51 variant: 'outline',
52 colorScheme: 'primary',
53 class: 'border-primary text-primary data-[hover=true]:bg-primary/10',
54 },
55 // Outline + Destructive
56 {
57 variant: 'outline',
58 colorScheme: 'destructive',
59 class: 'border-destructive text-destructive data-[hover=true]:bg-destructive/10',
60 },
61 // Ghost + Primary
62 {
63 variant: 'ghost',
64 colorScheme: 'primary',
65 class: 'text-primary data-[hover=true]:bg-primary/10',
66 },
67 // Ghost + Destructive
68 {
69 variant: 'ghost',
70 colorScheme: 'destructive',
71 class: 'text-destructive data-[hover=true]:bg-destructive/10',
72 },
73 ],
74 defaultVariants: {
75 variant: 'solid',
76 colorScheme: 'primary',
77 size: 'md',
78 },
79});
80
81export const ActionButton = ({
82 variant,
83 colorScheme,
84 size,
85 isLoading = false,
86 isDisabled = false,
87 className,
88 onPress,
89 children,
90}: ActionButtonProps) => {
91 return (
92 <Button
93 onPress={onPress}
94 isDisabled={isDisabled || isLoading}
95 className={actionButtonStyles({ variant, colorScheme, size, class: className })}
96 >
97 {isLoading && <ButtonIcon as={Loader2Icon} className="animate-spin" />}
98 {children}
99 </Button>
100 );
101};
102
103// Usage:
104// <ActionButton variant="solid" colorScheme="primary" size="lg">
105// <ButtonText>Primary Action</ButtonText>
106// </ActionButton>
107//
108// <ActionButton variant="outline" colorScheme="destructive" isLoading>
109// <ButtonText>Delete</ButtonText>
110// </ActionButton>
Key Points:
- ✅ Compound variants handle specific combinations
- ✅ Base variants provide defaults
- ✅ Hover states with data attributes
- ✅ Loading state integration
- ✅ Flexible variant combinations
Common Variant Patterns
Pattern 1: Status Badges
tsx
1const statusBadgeStyles = tva({
2 base: 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
3 variants: {
4 status: {
5 active: 'bg-primary/10 text-primary',
6 inactive: 'bg-muted text-muted-foreground',
7 pending: 'bg-accent/10 text-accent-foreground',
8 completed: 'bg-primary/10 text-primary',
9 failed: 'bg-destructive/10 text-destructive',
10 },
11 },
12 defaultVariants: {
13 status: 'inactive',
14 },
15});
16
17// Usage:
18// <Box className={statusBadgeStyles({ status: 'active' })}>
19// <Text>Active</Text>
20// </Box>
Pattern 2: Alert Variants
tsx
1const alertStyles = tva({
2 base: 'rounded-lg border p-4',
3 variants: {
4 severity: {
5 info: 'bg-secondary/10 border-secondary/20 text-secondary-foreground',
6 success: 'bg-primary/10 border-primary/20 text-primary',
7 warning: 'bg-accent/10 border-accent/20 text-accent-foreground',
8 error: 'bg-destructive/10 border-destructive/20 text-destructive',
9 },
10 },
11 defaultVariants: {
12 severity: 'info',
13 },
14});
15
16// Usage:
17// <Box className={alertStyles({ severity: 'error' })}>
18// <Text>Error message</Text>
19// </Box>
Pattern 3: Interactive Card States
tsx
1const interactiveCardStyles = tva({
2 base: 'rounded-lg border border-border p-4 transition-all cursor-pointer',
3 variants: {
4 state: {
5 default: 'bg-card data-[hover=true]:bg-muted/50',
6 selected: 'bg-primary/10 border-primary',
7 disabled: 'bg-muted opacity-60 cursor-not-allowed',
8 },
9 },
10 defaultVariants: {
11 state: 'default',
12 },
13});
14
15// Usage:
16// <Pressable>
17// <Box className={interactiveCardStyles({ state: 'selected' })}>
18// <Text>Selected Card</Text>
19// </Box>
20// </Pressable>
Pattern 4: Size Variants with Consistent Ratios
tsx
1const avatarStyles = tva({
2 base: 'rounded-full overflow-hidden',
3 variants: {
4 size: {
5 xs: 'w-6 h-6',
6 sm: 'w-8 h-8',
7 md: 'w-12 h-12',
8 lg: 'w-16 h-16',
9 xl: 'w-20 h-20',
10 '2xl': 'w-24 h-24',
11 },
12 },
13 defaultVariants: {
14 size: 'md',
15 },
16});
17
18// Usage:
19// <Image className={avatarStyles({ size: 'lg' })} source={{ uri: avatarUrl }} />
Best Practices for Variants
✅ Do's
-
Use semantic variant names
tsx
1// ✅ GOOD: Semantic names
2variant: 'primary' | 'secondary' | 'destructive'
3
4// ❌ BAD: Generic names
5variant: 'blue' | 'red' | 'green'
-
Provide default variants
tsx
1// ✅ GOOD: Always specify defaults
2defaultVariants: {
3 variant: 'default',
4 size: 'md',
5}
-
Use compound variants for combinations
tsx
1// ✅ GOOD: Handle specific combinations
2compoundVariants: [
3 {
4 variant: 'outline',
5 colorScheme: 'primary',
6 class: 'border-primary text-primary',
7 },
8]
-
Keep variant dimensions focused
tsx
1// ✅ GOOD: Clear separation
2variants: {
3 variant: { ... }, // Visual style
4 size: { ... }, // Size
5 state: { ... }, // Interactive state
6}
-
Use ONLY semantic tokens in variant styles - NO EXCEPTIONS
tsx
1// ✅ CORRECT: Semantic tokens with alpha values
2success: 'bg-primary/10 text-primary border-primary/20'
3error: 'bg-destructive/10 text-destructive border-destructive/20'
4muted: 'bg-muted text-muted-foreground border-border'
5
6// ❌ PROHIBITED: Numbered color tokens
7success: 'bg-green-100 text-green-800 border-green-200'
8error: 'bg-red-100 text-red-800 border-red-200'
9
10// ❌ PROHIBITED: Generic tokens
11muted: 'bg-neutral-100 text-neutral-600 border-neutral-300'
12muted: 'bg-gray-100 text-gray-600 border-gray-300'
13
14// ❌ PROHIBITED: Typography tokens
15text: 'text-typography-900'
❌ Don'ts
-
Don't create too many variant dimensions
tsx
1// ❌ BAD: Too many dimensions
2variants: {
3 variant: { ... },
4 size: { ... },
5 color: { ... },
6 border: { ... },
7 shadow: { ... },
8 rounded: { ... },
9}
10
11// ✅ GOOD: Focused dimensions
12variants: {
13 variant: { ... },
14 size: { ... },
15}
-
Don't mix concerns in variant names
tsx
1// ❌ BAD: Mixing visual and semantic
2variant: 'primary' | 'large-primary' | 'small-secondary'
3
4// ✅ GOOD: Separate dimensions
5variant: 'primary' | 'secondary'
6size: 'sm' | 'md' | 'lg'
-
Don't duplicate existing component props
tsx
1// ❌ BAD: Duplicating Button's variant prop
2const CustomButton = ({ variant, ... }: { variant: 'new1' | 'new2' })
3
4// ✅ GOOD: Extend existing variants
5const CustomButton = ({ variant, ... }: {
6 variant: 'default' | 'outline' | 'new1' | 'new2'
7})
CRITICAL: Semantic Tokens in Variants
ALL variant styles MUST use ONLY semantic tokens. This is NON-NEGOTIABLE.
Correct Variant Token Usage
tsx
1// ✅ CORRECT: All colors are semantic tokens
2const badgeStyles = tva({
3 base: 'inline-flex items-center rounded-full px-3 py-1',
4 variants: {
5 variant: {
6 default: 'bg-muted text-muted-foreground',
7 primary: 'bg-primary/10 text-primary border border-primary/20',
8 success: 'bg-primary/10 text-primary border border-primary/20',
9 error: 'bg-destructive/10 text-destructive border border-destructive/20',
10 warning: 'bg-accent/10 text-accent-foreground border border-accent/20',
11 },
12 },
13});
Prohibited Variant Token Usage
tsx
1// ❌ PROHIBITED: Using numbered color tokens
2const badgeStyles = tva({
3 variants: {
4 variant: {
5 success: 'bg-green-100 text-green-800 border-green-200', // ❌ NO
6 error: 'bg-red-100 text-red-800 border-red-200', // ❌ NO
7 warning: 'bg-yellow-100 text-yellow-800', // ❌ NO
8 },
9 },
10});
11
12// ❌ PROHIBITED: Using generic tokens
13const badgeStyles = tva({
14 variants: {
15 variant: {
16 default: 'bg-neutral-100 text-neutral-700', // ❌ NO
17 muted: 'bg-gray-100 text-gray-600', // ❌ NO
18 },
19 },
20});
21
22// ❌ PROHIBITED: Using typography tokens
23const textStyles = tva({
24 variants: {
25 variant: {
26 heading: 'text-typography-900', // ❌ NO
27 body: 'text-typography-700', // ❌ NO
28 },
29 },
30});
Token Replacement Guide for Variants
| Prohibited Pattern | Use Instead |
|---|
bg-green-100 text-green-800 | bg-primary/10 text-primary |
bg-red-100 text-red-800 | bg-destructive/10 text-destructive |
bg-yellow-100 text-yellow-800 | bg-accent/10 text-accent-foreground |
bg-blue-100 text-blue-800 | bg-primary/10 text-primary |
bg-neutral-100 text-neutral-700 | bg-muted text-muted-foreground |
bg-gray-100 text-gray-900 | bg-muted text-foreground |
text-typography-900 | text-foreground |
text-typography-600 | text-muted-foreground |
border-gray-300 | border-border |
Validation Checklist for Variants
When creating variants, verify:
Recipe: Converting Repeated Styles to Variants
Before: Repeated className Patterns
tsx
1// ❌ Repeated patterns across codebase
2<Box className="bg-primary/10 border border-primary/20 rounded-full px-3 py-1">
3 <Text className="text-xs text-primary font-semibold">Active</Text>
4</Box>
5
6<Box className="bg-destructive/10 border border-destructive/20 rounded-full px-3 py-1">
7 <Text className="text-xs text-destructive font-semibold">Error</Text>
8</Box>
9
10<Box className="bg-accent/10 border border-accent/20 rounded-full px-3 py-1">
11 <Text className="text-xs text-accent-foreground font-semibold">Pending</Text>
12</Box>
After: Variant-Based Component
tsx
1// ✅ GOOD: Single component with variants
2const StatusPill = ({ status, children }: StatusPillProps) => {
3 const pillStyles = tva({
4 base: 'inline-flex items-center rounded-full px-3 py-1',
5 variants: {
6 status: {
7 active: 'bg-primary/10 border border-primary/20',
8 error: 'bg-destructive/10 border border-destructive/20',
9 pending: 'bg-accent/10 border border-accent/20',
10 },
11 },
12 });
13
14 const textStyles = tva({
15 base: 'text-xs font-semibold',
16 parentVariants: {
17 status: {
18 active: 'text-primary',
19 error: 'text-destructive',
20 pending: 'text-accent-foreground',
21 },
22 },
23 });
24
25 return (
26 <Box className={pillStyles({ status })}>
27 <Text className={textStyles({ parentVariants: { status } })}>{children}</Text>
28 </Box>
29 );
30};
31
32// Usage:
33<StatusPill status="active">Active</StatusPill>
34<StatusPill status="error">Error</StatusPill>
35<StatusPill status="pending">Pending</StatusPill>
Troubleshooting
Issue: Variants Not Applying
Problem: Variant classes not showing up
Solution:
- Check Tailwind config includes tva patterns
- Verify className merge order
- Ensure no conflicting inline styles
Issue: Parent Variants Not Working
Problem: Child components don't respond to parent variants
Solution:
- Use context to share parent state
- Pass parentVariants object correctly
- Verify context provider wraps children
Issue: Type Errors with Variants
Problem: TypeScript errors with variant options
Solution:
- Define variant types in interface
- Use literal types for variant values
- Ensure defaultVariants match types
Reference