Step-by-step guide for creating components with gluestack-ui v4 - covers planning, structure, styling, TypeScript, and common component patterns.
This sub-skill provides practical guidance for creating new components using gluestack-ui v4, from planning to implementation.
Before writing code, answer these questions:
What is the component's purpose?
Which Gluestack components do I need?
https://v4.gluestack.io/ui/docs/components/${componentName}/Does it need compound components?
What props should it accept?
sm, md, lgdefault, outline, ghostisDisabled, isInvalid, isLoadingDoes it need variants?
tva (Tailwind Variant Authority)ALWAYS verify component usage before creating:
# Visit official docs for the component
https://v4.gluestack.io/ui/docs/components/${componentName}/
Check for:
Follow this file structure:
components/
├── ui/ # Gluestack UI components (copy-paste)
│ ├── box/
│ ├── button/
│ └── input/
└── custom/ # Your custom components
├── profile-card/
│ └── index.tsx
└── login-form/
└── index.tsx
Use when component has consistent styling without variants.
import React from 'react';
import { Box } from '@/components/ui/box';
import { Text } from '@/components/ui/text';
import { Heading } from '@/components/ui/heading';
interface ProfileCardProps {
readonly name: string;
readonly email: string;
readonly className?: string;
}
export const ProfileCard = ({ name, email, className }: ProfileCardProps) => {
return (
<Box className={`bg-card rounded-lg border border-border p-4 ${className || ''}`}>
<Heading size="lg" className="text-card-foreground">
{name}
</Heading>
<Text size="sm" className="text-muted-foreground mt-1">
{email}
</Text>
</Box>
);
};
Key points:
readonly propsUse when component needs multiple visual styles or sizes.
import React from 'react';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import { Box } from '@/components/ui/box';
import { Text } from '@/components/ui/text';
interface AlertProps {
readonly variant?: 'default' | 'success' | 'warning' | 'destructive';
readonly size?: 'sm' | 'md' | 'lg';
readonly className?: string;
readonly children: React.ReactNode;
}
const alertStyles = tva({
base: 'rounded-lg border p-4',
variants: {
variant: {
default: 'bg-card border-border',
success: 'bg-primary/10 border-primary',
warning: 'bg-accent/10 border-accent',
destructive: 'bg-destructive/10 border-destructive',
},
size: {
sm: 'p-2',
md: 'p-4',
lg: 'p-6',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
});
const alertTextStyles = tva({
base: 'font-sans',
parentVariants: {
variant: {
default: 'text-foreground',
success: 'text-primary',
warning: 'text-accent-foreground',
destructive: 'text-destructive',
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
},
},
});
export const Alert = ({ variant, size, className, children }: AlertProps) => {
return (
<Box className={alertStyles({ variant, size, class: className })}>
<Text className={alertTextStyles({ parentVariants: { variant, size } })}>
{children}
</Text>
</Box>
);
};
Key points:
Use when component has multiple related sub-components.
import React from 'react';
import { Box } from '@/components/ui/box';
import { Heading } from '@/components/ui/heading';
import { Text } from '@/components/ui/text';
import { HStack } from '@/components/ui/hstack';
// Main Card Component
interface CardProps {
readonly className?: string;
readonly children: React.ReactNode;
}
export const Card = ({ className, children }: CardProps) => {
return (
<Box className={`bg-card rounded-lg border border-border shadow-sm ${className || ''}`}>
{children}
</Box>
);
};
// Card Header Sub-component
interface CardHeaderProps {
readonly className?: string;
readonly children: React.ReactNode;
}
export const CardHeader = ({ className, children }: CardHeaderProps) => {
return (
<Box className={`p-4 border-b border-border ${className || ''}`}>
{children}
</Box>
);
};
// Card Body Sub-component
interface CardBodyProps {
readonly className?: string;
readonly children: React.ReactNode;
}
export const CardBody = ({ className, children }: CardBodyProps) => {
return (
<Box className={`p-4 ${className || ''}`}>
{children}
</Box>
);
};
// Card Footer Sub-component
interface CardFooterProps {
readonly className?: string;
readonly children: React.ReactNode;
}
export const CardFooter = ({ className, children }: CardFooterProps) => {
return (
<HStack space="md" className={`p-4 border-t border-border ${className || ''}`}>
{children}
</HStack>
);
};
// Usage
// <Card>
// <CardHeader>
// <Heading size="lg">Title</Heading>
// </CardHeader>
// <CardBody>
// <Text>Content</Text>
// </CardBody>
// <CardFooter>
// <Button>Action</Button>
// </CardFooter>
// </Card>
Key points:
Use for form inputs with labels, validation, and error messages.
import React, { useState } from 'react';
import { FormControl, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
import { FormControlError, FormControlErrorIcon, FormControlErrorText } from '@/components/ui/form-control';
import { FormControlHelper, FormControlHelperText } from '@/components/ui/form-control';
import { Input, InputField, InputSlot, InputIcon } from '@/components/ui/input';
import { MailIcon, AlertCircleIcon } from '@/components/ui/icon';
interface EmailInputProps {
readonly label?: string;
readonly placeholder?: string;
readonly helperText?: string;
readonly value: string;
readonly error?: string;
readonly onChange: (value: string) => void;
readonly className?: string;
}
export const EmailInput = ({
label = 'Email Address',
placeholder = 'Enter your email',
helperText,
value,
error,
onChange,
className,
}: EmailInputProps) => {
const [isFocused, setIsFocused] = useState(false);
return (
<FormControl isInvalid={!!error} className={className}>
<FormControlLabel>
<FormControlLabelText>{label}</FormControlLabelText>
</FormControlLabel>
<Input>
<InputSlot>
<InputIcon
as={MailIcon}
className={isFocused ? 'text-primary' : 'text-muted-foreground'}
/>
</InputSlot>
<InputField
placeholder={placeholder}
value={value}
onChangeText={onChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
keyboardType="email-address"
autoCapitalize="none"
/>
</Input>
{error && (
<FormControlError>
<FormControlErrorIcon as={AlertCircleIcon} />
<FormControlErrorText>{error}</FormControlErrorText>
</FormControlError>
)}
{helperText && !error && (
<FormControlHelper>
<FormControlHelperText>{helperText}</FormControlHelperText>
</FormControlHelper>
)}
</FormControl>
);
};
Key points:
Use for components with internal state and interactions.
import React, { useState } from 'react';
import { Box } from '@/components/ui/box';
import { Text } from '@/components/ui/text';
import { Pressable } from '@/components/ui/pressable';
import { ChevronDownIcon, ChevronUpIcon } from '@/components/ui/icon';
import { Icon } from '@/components/ui/icon';
import { VStack } from '@/components/ui/vstack';
import { HStack } from '@/components/ui/hstack';
interface AccordionProps {
readonly title: string;
readonly children: React.ReactNode;
readonly defaultExpanded?: boolean;
readonly className?: string;
}
export const Accordion = ({
title,
children,
defaultExpanded = false,
className,
}: AccordionProps) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<Box className={`border border-border rounded-lg overflow-hidden ${className || ''}`}>
<Pressable onPress={() => setIsExpanded(!isExpanded)}>
<HStack
space="md"
className="p-4 bg-card items-center justify-between"
>
<Text size="md" bold className="text-card-foreground flex-1">
{title}
</Text>
<Icon
as={isExpanded ? ChevronUpIcon : ChevronDownIcon}
size="md"
className="text-muted-foreground"
/>
</HStack>
</Pressable>
{isExpanded && (
<Box className="p-4 bg-background border-t border-border">
{children}
</Box>
)}
</Box>
);
};
Key points:
Use for components that fetch data or perform async operations.
import React from 'react';
import { Button, ButtonText, ButtonSpinner, ButtonIcon } from '@/components/ui/button';
import { CheckIcon } from '@/components/ui/icon';
interface SubmitButtonProps {
readonly isLoading?: boolean;
readonly isSuccess?: boolean;
readonly onPress: () => void;
readonly className?: string;
}
export const SubmitButton = ({
isLoading = false,
isSuccess = false,
onPress,
className,
}: SubmitButtonProps) => {
return (
<Button
variant="default"
size="lg"
onPress={onPress}
isDisabled={isLoading || isSuccess}
className={className}
>
{isLoading && <ButtonSpinner />}
{isSuccess && <ButtonIcon as={CheckIcon} />}
<ButtonText>
{isLoading ? 'Submitting...' : isSuccess ? 'Success!' : 'Submit'}
</ButtonText>
</Button>
);
};
Key points:
import React from 'react';
import { Box } from '@/components/ui/box';
import { HStack } from '@/components/ui/hstack';
import { VStack } from '@/components/ui/vstack';
import { Text } from '@/components/ui/text';
import { Heading } from '@/components/ui/heading';
import { Image } from '@/components/ui/image';
interface ProfileCardProps {
readonly name: string;
readonly email: string;
readonly avatarUrl: string;
readonly bio?: string;
readonly className?: string;
}
export const ProfileCard = ({
name,
email,
avatarUrl,
bio,
className,
}: ProfileCardProps) => {
return (
<Box className={`bg-card rounded-lg border border-border p-4 ${className || ''}`}>
<HStack space="lg" className="items-start">
<Image
source={{ uri: avatarUrl }}
alt={`${name}'s avatar`}
className="w-16 h-16 rounded-full"
/>
<VStack space="sm" className="flex-1">
<Heading size="lg" className="text-card-foreground">
{name}
</Heading>
<Text size="sm" className="text-muted-foreground">
{email}
</Text>
{bio && (
<Text size="sm" className="text-foreground mt-2">
{bio}
</Text>
)}
</VStack>
</HStack>
</Box>
);
};
import React from 'react';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import { Box } from '@/components/ui/box';
import { Text } from '@/components/ui/text';
interface StatusBadgeProps {
readonly status: 'active' | 'inactive' | 'pending' | 'error';
readonly className?: string;
}
const badgeStyles = tva({
base: 'rounded-full px-3 py-1 inline-flex',
variants: {
status: {
active: 'bg-primary/10',
inactive: 'bg-muted',
pending: 'bg-accent/10',
error: 'bg-destructive/10',
},
},
});
const badgeTextStyles = tva({
base: 'text-xs font-medium',
parentVariants: {
status: {
active: 'text-primary',
inactive: 'text-muted-foreground',
pending: 'text-accent-foreground',
error: 'text-destructive',
},
},
});
export const StatusBadge = ({ status, className }: StatusBadgeProps) => {
const labels = {
active: 'Active',
inactive: 'Inactive',
pending: 'Pending',
error: 'Error',
};
return (
<Box className={badgeStyles({ status, class: className })}>
<Text className={badgeTextStyles({ parentVariants: { status } })}>
{labels[status]}
</Text>
</Box>
);
};
import React from 'react';
import { Input, InputField, InputSlot, InputIcon } from '@/components/ui/input';
import { SearchIcon, XIcon } from '@/components/ui/icon';
import { Pressable } from '@/components/ui/pressable';
interface SearchInputProps {
readonly value: string;
readonly onChange: (value: string) => void;
readonly placeholder?: string;
readonly onClear?: () => void;
readonly className?: string;
}
export const SearchInput = ({
value,
onChange,
placeholder = 'Search...',
onClear,
className,
}: SearchInputProps) => {
const handleClear = () => {
onChange('');
onClear?.();
};
return (
<Input className={className}>
<InputSlot>
<InputIcon as={SearchIcon} className="text-muted-foreground" />
</InputSlot>
<InputField
placeholder={placeholder}
value={value}
onChangeText={onChange}
autoCapitalize="none"
/>
{value.length > 0 && (
<InputSlot onPress={handleClear}>
<InputIcon as={XIcon} className="text-muted-foreground" />
</InputSlot>
)}
</Input>
);
};
import React from 'react';
import { Pressable } from '@/components/ui/pressable';
import { HStack } from '@/components/ui/hstack';
import { VStack } from '@/components/ui/vstack';
import { Box } from '@/components/ui/box';
import { Text } from '@/components/ui/text';
import { Icon } from '@/components/ui/icon';
import { ChevronRightIcon } from '@/components/ui/icon';
interface ListItemProps {
readonly title: string;
readonly description?: string;
readonly onPress: () => void;
readonly showChevron?: boolean;
readonly className?: string;
}
export const ListItem = ({
title,
description,
onPress,
showChevron = true,
className,
}: ListItemProps) => {
return (
<Pressable onPress={onPress}>
<Box
className={`
p-4 border-b border-border bg-background
data-[hover=true]:bg-muted/50
${className || ''}
`}
>
<HStack space="md" className="items-center justify-between">
<VStack space="xs" className="flex-1">
<Text size="md" className="text-foreground">
{title}
</Text>
{description && (
<Text size="sm" className="text-muted-foreground">
{description}
</Text>
)}
</VStack>
{showChevron && (
<Icon
as={ChevronRightIcon}
size="md"
className="text-muted-foreground"
/>
)}
</HStack>
</Box>
</Pressable>
);
};
Before creating any component, understand these STRICT token requirements:
Use ONLY these semantic token patterns:
Text Colors:
text-foreground - Main text colortext-muted-foreground - Muted/secondary texttext-card-foreground - Text on card backgroundstext-primary, text-primary-foreground - Primary brand colorstext-secondary, text-secondary-foreground - Secondary colorstext-destructive - Error statestext-accent, text-accent-foreground - Accent colorstext-foreground/70, text-primary/90Background Colors:
bg-background - Main backgroundbg-card - Card backgroundsbg-muted - Muted backgroundsbg-popover - Popover/modal backgroundsbg-primary, bg-secondary, bg-destructive, bg-accent - Action colorsbg-primary/10, bg-muted/50Border Colors:
border-border - Standard bordersborder-input - Input bordersring-ring - Focus ringsborder-border/50, border-primary/20NEVER use these token patterns - they are STRICTLY PROHIBITED:
// ❌ PROHIBITED: Generic typography tokens
text-typography-900
text-typography-700
text-typography-500
// ❌ PROHIBITED: Neutral color tokens
bg-neutral-100
text-neutral-600
border-neutral-300
// ❌ PROHIBITED: Gray/Slate color scales
bg-gray-50
text-gray-900
border-gray-200
text-slate-700
// ❌ PROHIBITED: Numbered color tokens
bg-blue-600
text-red-500
border-green-400
bg-indigo-500
// ❌ PROHIBITED: Arbitrary values
bg-[#3b82f6]
text-[#DC2626]
// ❌ PROHIBITED: Opacity utilities
opacity-70
bg-opacity-90
text-opacity-80
Using prohibited tokens will:
Using semantic tokens will:
When creating a component, verify:
@/components/ui/*readonly props? markertypography-*, neutral-*, gray-*, slate-*, or numbered colors (red-500, blue-600)sm, md, lgisDisabled, isLoading, isInvalid// ❌ INCORRECT
import { View, Text } from 'react-native';
import { Button } from '@/components/ui/button';
export const Component = () => (
<View>
<Text>Content</Text>
</View>
);
// ❌ INCORRECT: Missing ButtonText
<Button onPress={handlePress}>Submit</Button>
// ✅ CORRECT
<Button onPress={handlePress}>
<ButtonText>Submit</ButtonText>
</Button>
// ❌ INCORRECT
<Box style={{ padding: 16, backgroundColor: '#fff' }}>
// ✅ CORRECT
<Box className="p-4 bg-background">
// ❌ INCORRECT: InputIcon not wrapped
<Input>
<InputIcon as={MailIcon} />
<InputField />
</Input>
// ✅ CORRECT
<Input>
<InputSlot>
<InputIcon as={MailIcon} />
</InputSlot>
<InputField />
</Input>
https://v4.gluestack.io/ui/docs/components/${componentName}/