Styling & Theming
This page covers comprehensive styling standards for Decentraland web UIs using styled-components with Material UI's styling solution.
All code examples in this document are illustrative. They do not represent production components but demonstrate patterns and standards.
Core Principles
- Object syntax only - Use object notation for strong TypeScript support
- Theme-first - All values come from the UI2 theme
- No inline styles - Use styled components for all styling
- Typed everything - Leverage TypeScript for props and theme
- Responsive by default - Use theme breakpoints
Object Syntax Standard
UI2 components MUST use the object syntax. This ensures strong TypeScript support, csstype validation, and direct theme integration.
Template literal syntax is not allowed. Always use object notation.
Basic Example
// ✅ Good: Object syntax
const Button = styled('button')({
color: 'turquoise',
padding: '8px 16px',
});
// ❌ Bad: Template literal syntax
const Button = styled.button`
color: turquoise;
padding: 8px 16px;
`;
With Theme and Props
interface ButtonProps {
primary?: boolean;
size?: 'small' | 'medium' | 'large';
}
// ✅ Good: Object syntax with theme and props
const Button = styled('button')<ButtonProps>(({ theme, primary, size = 'medium' }) => ({
color: primary ? theme.palette.primary.main : theme.palette.text.primary,
backgroundColor: primary ? theme.palette.primary.main : 'transparent',
borderRadius: theme.shape.borderRadius,
padding: {
small: theme.spacing(0.5, 1),
medium: theme.spacing(1, 2),
large: theme.spacing(1.5, 3),
}[size],
}));
Rule: Values MUST always come from the UI2 theme. Arbitrary hex codes or pixel values are not allowed.
Element Syntax
When styling native HTML elements, always use the function call form styled('tag').
Correct Syntax
// ✅ Good: Function call form
const Container = styled('div')({
display: 'flex',
flexDirection: 'column',
});
const Action = styled('button')(({ theme }) => ({
color: theme.palette.primary.main,
padding: theme.spacing(1, 2),
}));
const Label = styled('label')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.typography.caption.fontSize,
}));
Incorrect Syntax
// ❌ Bad: Property form (legacy syntax)
const Container = styled.div`
display: flex;
flex-direction: column;
`;
const Action = styled.button`
color: ${props => props.theme.palette.primary.main};
`;
No Inline Styles
Inline styles (style={...}) MUST NOT be used in UI2 components.
Why Not Inline Styles?
- Bypass theme typing
- Harder to maintain
- Prevent reusability
- Can't be optimized
- No TypeScript validation
The Right Way
// ❌ Bad: Inline styles
<Card
key={id}
style={{ backgroundColor: color } as React.CSSProperties}
>
<CardHeader>
<Title style={{ fontSize: 20 }}>{title}</Title>
</CardHeader>
</Card>
// ✅ Good: Styled components with props
interface CardProps {
backgroundColor: string;
}
interface TitleProps {
size: number;
}
const StyledCard = styled('div')<CardProps>(({ backgroundColor, theme }) => ({
backgroundColor,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2),
}));
const Title = styled('h2')<TitleProps>(({ size, theme }) => ({
fontSize: theme.typography.pxToRem(size),
color: theme.palette.text.primary,
}));
<StyledCard key={id} backgroundColor={color}>
<CardHeader>
<Title size={20}>{title}</Title>
</CardHeader>
</StyledCard>
Breakpoints
Use theme.breakpoints helpers instead of hardcoded pixel values.
Breakpoint Helpers
| Helper | Usage | Description |
|---|---|---|
up(key) |
theme.breakpoints.up('md') |
Min-width and up |
down(key) |
theme.breakpoints.down('md') |
Max-width and down |
between(start, end) |
theme.breakpoints.between('sm', 'lg') |
Between two breakpoints |
only(key) |
theme.breakpoints.only('md') |
Only at this breakpoint |
Examples
// ❌ Bad: Hardcoded breakpoints
const Panel = styled('div')`
@media (max-width: 768px) {
width: 100%;
}
`;
// ✅ Good: Theme breakpoints
interface PanelProps {
expanded: boolean;
}
const Panel = styled('div')<PanelProps>(({ theme, expanded }) => ({
width: expanded ? '400px' : '0',
transition: theme.transitions.create('width'),
[theme.breakpoints.down('sm')]: {
width: expanded ? '100%' : '0',
},
}));
Multiple Breakpoints
const Layout = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: '1fr 320px',
gap: theme.spacing(2),
// Mobile: single column
[theme.breakpoints.down('md')]: {
gridTemplateColumns: '1fr',
},
// Large desktop: wider sidebar
[theme.breakpoints.up('xl')]: {
gridTemplateColumns: '1fr 400px',
},
// Tablet range: different gap
[theme.breakpoints.between('sm', 'lg')]: {
gap: theme.spacing(3),
},
// Only tablet
[theme.breakpoints.only('md')]: {
padding: theme.spacing(2),
},
}));
Spacing Scale
Use theme.spacing exclusively for margins, paddings, and gaps.
Spacing Convention
theme.spacing(n)wherenis a number- Base unit is typically 8px
spacing(1)= 8px,spacing(2)= 16px, etc.- Decimals allowed:
spacing(1.5)= 12px
// ✅ Good: Theme spacing
const Box = styled('div')(({ theme }) => ({
padding: theme.spacing(2), // 16px
margin: theme.spacing(1, 0), // 8px vertical, 0 horizontal
gap: theme.spacing(1.5), // 12px
paddingInline: theme.spacing(3), // 24px left/right
}));
// ❌ Bad: Raw pixel values
const Box = styled('div')({
padding: '16px',
margin: '8px 0',
gap: '12px',
});
Common Spacing Patterns
const Card = styled('div')(({ theme }) => ({
// Even spacing all around
padding: theme.spacing(3),
// Different vertical/horizontal
padding: theme.spacing(2, 3), // 16px vertical, 24px horizontal
// All sides different
padding: theme.spacing(1, 2, 3, 2), // top, right, bottom, left
// Logical properties (preferred for RTL support)
paddingBlock: theme.spacing(2), // top and bottom
paddingInline: theme.spacing(3), // left and right
marginBlockStart: theme.spacing(1), // margin-top
}));
Z-Index and Stacking
Follow the theme's z-index scale. Never use arbitrary z-index values.
Theme Z-Index Values
theme.zIndex.mobileStepper // 1000
theme.zIndex.fab // 1050
theme.zIndex.speedDial // 1050
theme.zIndex.appBar // 1100
theme.zIndex.drawer // 1200
theme.zIndex.modal // 1300
theme.zIndex.snackbar // 1400
theme.zIndex.tooltip // 1500
Correct Usage
// ✅ Good: Theme z-index
const StickyBar = styled('div')(({ theme }) => ({
position: 'sticky',
top: 0,
zIndex: theme.zIndex.appBar,
backgroundColor: theme.palette.background.paper,
}));
const Overlay = styled('div')(({ theme }) => ({
position: 'fixed',
inset: 0,
zIndex: theme.zIndex.modal,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
}));
// ❌ Bad: Arbitrary z-index
const StickyBar = styled('div')({
position: 'sticky',
top: 0,
zIndex: 999, // Don't do this
});
Stacking Context Best Practices
- Be conscious of creating new stacking contexts
- Avoid unnecessary
position: relativeon parents - Document why a z-index is needed
- If you need a new layer, add it to the theme first
Color Tokens
Always consume colors from theme.palette and dclColors. No ad-hoc hex values.
Palette Structure
// Text colors
theme.palette.text.primary
theme.palette.text.secondary
theme.palette.text.disabled
// Background colors
theme.palette.background.default
theme.palette.background.paper
// Primary/Secondary/Error
theme.palette.primary.main
theme.palette.primary.light
theme.palette.primary.dark
theme.palette.primary.contrastText
// Action colors
theme.palette.action.active
theme.palette.action.hover
theme.palette.action.selected
theme.palette.action.disabled
theme.palette.action.disabledBackground
// Dividers
theme.palette.divider
Decentraland Colors
import { dclColors } from 'decentraland-ui2';
// Rarity colors
dclColors.rarity.unique
dclColors.rarity.mythic
dclColors.rarity.legendary
dclColors.rarity.epic
dclColors.rarity.rare
dclColors.rarity.uncommon
dclColors.rarity.common
Examples
// ✅ Good: Theme colors
const Chip = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}));
const RarityBadge = styled('span')<{ rarity: string }>(({ rarity }) => ({
backgroundColor: dclColors.rarity[rarity],
color: '#FFFFFF', // Contrast color - acceptable if documented
padding: '4px 8px',
borderRadius: '4px',
}));
// ❌ Bad: Arbitrary hex values
const Chip = styled('span')({
color: '#666666',
backgroundColor: '#FFFFFF',
borderColor: '#E0E0E0',
});
Interactive States
All interactive controls MUST show visible states for hover, focus, active, and disabled.
Complete Interactive Component
const InteractiveButton = styled('button')(({ theme }) => ({
// Base state
padding: theme.spacing(1, 2),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
border: 'none',
borderRadius: theme.shape.borderRadius,
cursor: 'pointer',
outline: 'none',
transition: theme.transitions.create([
'background-color',
'transform',
'box-shadow',
]),
// Hover state (mouse)
'&:hover': {
backgroundColor: theme.palette.primary.dark,
},
// Focus state (keyboard navigation)
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2,
boxShadow: theme.shadows[2],
},
// Active/Pressed state
'&:active': {
backgroundColor: theme.palette.primary.dark,
transform: 'scale(0.98)',
},
// Disabled state
'&:disabled': {
backgroundColor: theme.palette.action.disabledBackground,
color: theme.palette.action.disabled,
cursor: 'not-allowed',
transform: 'none',
},
}));
Focus-Visible Pattern
Always use :focus-visible instead of :focus to avoid showing focus rings on mouse clicks:
// ✅ Good: Only shows focus ring for keyboard
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2,
}
// ❌ Bad: Shows focus ring on every click
'&:focus': {
outline: `2px solid ${theme.palette.primary.main}`,
}
Typography
Use theme typography variants instead of custom font properties.
// ✅ Good: Typography variants
const Heading = styled('h1')(({ theme }) => ({
...theme.typography.h1,
marginBottom: theme.spacing(2),
}));
const Body = styled('p')(({ theme}) => ({
...theme.typography.body1,
color: theme.palette.text.secondary,
}));
// With MUI Typography component (preferred)
<Typography variant="h1">Heading</Typography>
<Typography variant="body1">Body text</Typography>
// ❌ Bad: Custom typography
const Heading = styled('h1')({
fontSize: '32px',
fontWeight: 700,
lineHeight: 1.2,
});
Performance Optimization
Don't Create Styled Components in Render
// ❌ Bad: Creates new component on every render
function Component({ color }) {
const Box = styled('div')({
backgroundColor: color,
});
return <Box />;
}
// ✅ Good: Create once, pass props
const Box = styled('div')<{ color: string }>(({ color }) => ({
backgroundColor: color,
}));
function Component({ color }) {
return <Box color={color} />;
}
Memoize Derived Props
// ❌ Bad: Creates new object on every render
<StyledComponent style={{ color: isDark ? 'white' : 'black' }} />
// ✅ Good: Pass primitive props
const StyledComponent = styled('div')<{ isDark: boolean }>(({ isDark, theme }) => ({
color: isDark ? theme.palette.common.white : theme.palette.common.black,
}));
<StyledComponent isDark={isDark} />
Naming Conventions
Component Names
- PascalCase for components
- Descriptive names
// ✅ Good names
const UserCard = styled('div')({...});
const PrimaryButton = styled('button')({...});
const NavigationList = styled('ul')({...});
// ❌ Bad names
const card = styled('div')({...});
const btn = styled('button')({...});
const list1 = styled('ul')({...});
File Structure
Component.tsx # Main component
Component.styles.ts # Styled components
Component.test.tsx # Tests
Component.stories.tsx # Storybook
Next Steps
- Review Custom Components for component creation guidelines
- See Migration Guide for UI1 to UI2 migrations
- Check Process Overview for the complete workflow