Styling
This guide covers all the ways to style your Remix V3 application -- from traditional static CSS files to the css() mixin for dynamic inline styles, and the animation primitives for transitions and layout effects.
Static CSS Files
The simplest approach is serving plain CSS files with the static file middleware. This works well for global styles, resets, and third-party CSS.
Setting Up Static Files
import { staticFiles } from 'remix/static-middleware'
let router = createRouter({
middleware: [
staticFiles('./public'),
],
})Place your CSS files in the public directory:
public/
styles/
global.css
reset.css
components.cssReference them in your HTML:
return html`
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" href="/styles/reset.css" />
<link rel="stylesheet" href="/styles/global.css" />
</head>
<body>
<!-- ... -->
</body>
</html>
`Caching Static Files
The static middleware automatically handles ETags and conditional requests. For aggressive caching with fingerprinted filenames:
staticFiles('./public', {
maxAge: 60 * 60 * 24 * 365, // 1 year
immutable: true,
})Use fingerprinted filenames for cache busting
Name your files with a hash or version number (e.g., styles.abc123.css). When the file changes, the filename changes, and browsers fetch the new version. The immutable cache header tells browsers they never need to revalidate the file.
The css() Mixin
The css() function from remix/component lets you write styles inline as JavaScript objects. It generates scoped class names at runtime, avoiding naming conflicts.
import { css } from 'remix/component'Basic Usage
Apply styles to elements using the mix attribute:
<div mix={css({
color: 'blue',
fontSize: '16px',
padding: '20px',
borderRadius: '8px',
backgroundColor: '#f0f0f0',
})}>
Styled content
</div>CSS property names use camelCase (the same convention as element.style in JavaScript). Values are strings.
Pseudo-Selectors
Add pseudo-selectors by nesting them with a colon prefix:
<a mix={css({
color: '#333',
textDecoration: 'none',
transition: 'color 0.2s ease',
':hover': {
color: '#0066cc',
textDecoration: 'underline',
},
':focus': {
outline: '2px solid #0066cc',
outlineOffset: '2px',
},
':active': {
color: '#004499',
},
':visited': {
color: '#663399',
},
})} href="/about">
About
</a>Pseudo-Elements
<blockquote mix={css({
position: 'relative',
paddingLeft: '24px',
fontStyle: 'italic',
color: '#555',
'::before': {
content: '"\u201C"',
position: 'absolute',
left: '0',
top: '-4px',
fontSize: '32px',
color: '#ccc',
},
})}>
The web is the platform.
</blockquote>Media Queries
Responsive styles use @media queries as keys:
<div mix={css({
display: 'grid',
gap: '24px',
gridTemplateColumns: '1fr',
'@media (min-width: 640px)': {
gridTemplateColumns: 'repeat(2, 1fr)',
},
'@media (min-width: 1024px)': {
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '32px',
},
})}>
{/* Grid items */}
</div>Container Queries
<div mix={css({
containerType: 'inline-size',
})}>
<div mix={css({
fontSize: '14px',
'@container (min-width: 400px)': {
fontSize: '16px',
},
'@container (min-width: 800px)': {
fontSize: '18px',
},
})}>
Responsive to container size
</div>
</div>Combining Multiple Mixins
Pass an array to the mix attribute to combine multiple style objects:
let baseButton = css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px 16px',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
border: 'none',
transition: 'background-color 0.2s ease',
})
let primaryButton = css({
backgroundColor: '#3b82f6',
color: 'white',
':hover': {
backgroundColor: '#2563eb',
},
})
let dangerButton = css({
backgroundColor: '#ef4444',
color: 'white',
':hover': {
backgroundColor: '#dc2626',
},
})
// Usage
<button mix={[baseButton, primaryButton]}>Save</button>
<button mix={[baseButton, dangerButton]}>Delete</button>CSS-in-JS Patterns
Component-Scoped Styles
Define styles alongside the component that uses them:
// app/components/Card.ts
import { css } from 'remix/component'
let cardStyles = {
wrapper: css({
border: '1px solid #e5e7eb',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
transition: 'box-shadow 0.2s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
}),
image: css({
width: '100%',
height: '200px',
objectFit: 'cover',
}),
body: css({
padding: '16px',
}),
title: css({
margin: '0 0 8px 0',
fontSize: '18px',
fontWeight: '600',
color: '#111827',
}),
description: css({
margin: '0',
fontSize: '14px',
color: '#6b7280',
lineHeight: '1.5',
}),
}
export function Card(props: { title: string; description: string; imageUrl: string }) {
return (
<div mix={cardStyles.wrapper}>
<img mix={cardStyles.image} src={props.imageUrl} alt="" />
<div mix={cardStyles.body}>
<h3 mix={cardStyles.title}>{props.title}</h3>
<p mix={cardStyles.description}>{props.description}</p>
</div>
</div>
)
}Dynamic Styles
Since css() takes a plain JavaScript object, you can compute styles dynamically:
function Badge(props: { color: 'green' | 'red' | 'yellow' | 'blue' }) {
let colors = {
green: { bg: '#dcfce7', text: '#166534' },
red: { bg: '#fee2e2', text: '#991b1b' },
yellow: { bg: '#fef9c3', text: '#854d0e' },
blue: { bg: '#dbeafe', text: '#1e40af' },
}
let c = colors[props.color]
return (
<span mix={css({
display: 'inline-block',
padding: '2px 8px',
borderRadius: '9999px',
fontSize: '12px',
fontWeight: '500',
backgroundColor: c.bg,
color: c.text,
})}>
{props.children}
</span>
)
}Theme Variables
Use CSS custom properties for theming:
let theme = css({
'--color-primary': '#3b82f6',
'--color-primary-hover': '#2563eb',
'--color-text': '#111827',
'--color-text-muted': '#6b7280',
'--color-bg': '#ffffff',
'--color-border': '#e5e7eb',
'--radius-sm': '4px',
'--radius-md': '8px',
'--radius-lg': '12px',
'--shadow-sm': '0 1px 2px rgba(0, 0, 0, 0.05)',
'--shadow-md': '0 4px 6px rgba(0, 0, 0, 0.1)',
})
// Apply to the root element
<html mix={theme}>
// Use variables in components
let button = css({
backgroundColor: 'var(--color-primary)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-sm)',
':hover': {
backgroundColor: 'var(--color-primary-hover)',
},
})Dark Mode
Use a media query or a class-based approach:
// Media query approach
let card = css({
backgroundColor: '#ffffff',
color: '#111827',
border: '1px solid #e5e7eb',
'@media (prefers-color-scheme: dark)': {
backgroundColor: '#1f2937',
color: '#f9fafb',
border: '1px solid #374151',
},
})Animations
Remix V3 provides animation primitives for smooth, physics-based animations.
spring()
Spring animations produce natural-feeling motion based on physics:
import { spring } from 'remix/component'
// Basic spring
let springValue = spring(0) // Start at 0
// Configure the spring
let springValue = spring(0, {
stiffness: 0.15, // How quickly it reaches the target (0 to 1)
damping: 0.8, // How much it oscillates (0 = lots, 1 = none)
})tween()
Tween animations interpolate between values over a fixed duration:
import { tween, easings } from 'remix/component'
let tweenValue = tween(0, {
duration: 300, // Duration in milliseconds
easing: easings.easeOutCubic, // Easing function
})Available Easings
import { easings } from 'remix/component'
easings.linear // Constant speed
easings.easeInQuad // Slow start
easings.easeOutQuad // Slow end
easings.easeInOutQuad // Slow start and end
easings.easeInCubic // Slower start
easings.easeOutCubic // Slower end
easings.easeInOutCubic // Slower start and end
easings.easeInElastic // Elastic start
easings.easeOutElastic // Elastic end (bouncy)
easings.easeOutBounce // Bouncing endEntrance Animations
Animate elements when they first appear:
import { animateEntrance, css } from 'remix/component'
<div mix={[
css({ opacity: '1', transform: 'translateY(0)' }),
animateEntrance({
from: { opacity: '0', transform: 'translateY(20px)' },
duration: 400,
easing: easings.easeOutCubic,
}),
]}>
This fades in and slides up
</div>Exit Animations
Animate elements when they are removed:
import { animateExit } from 'remix/component'
<div mix={animateExit({
to: { opacity: '0', transform: 'scale(0.95)' },
duration: 200,
easing: easings.easeInCubic,
})}>
This fades out when removed
</div>Layout Animations
animateLayout() smoothly transitions an element's position and size when the layout changes:
import { animateLayout } from 'remix/component'
<div mix={animateLayout({
duration: 300,
easing: easings.easeInOutCubic,
})}>
This smoothly moves when its position changes
</div>Layout animations are useful for lists that reorder, grids that resize, or any element that changes position due to a layout shift.
Combining Animations
let fadeSlideIn = [
css({
opacity: '1',
transform: 'translateY(0)',
}),
animateEntrance({
from: { opacity: '0', transform: 'translateY(16px)' },
duration: 500,
easing: easings.easeOutCubic,
}),
animateExit({
to: { opacity: '0', transform: 'translateY(-16px)' },
duration: 300,
easing: easings.easeInCubic,
}),
animateLayout({ duration: 300 }),
]
// Apply to list items
{items.map(item => (
<div key={item.id} mix={fadeSlideIn}>
{item.name}
</div>
))}Best Practices
Prefer Static CSS for Global Styles
Use static CSS files for:
- CSS resets and normalizations
- Typography scales
- Color palettes and CSS custom properties
- Third-party library styles
Use css() for:
- Component-specific styles
- Dynamic styles based on props or state
- Pseudo-selectors tied to component logic
Organize Styles by Component
Keep styles close to the components that use them. This makes it easy to find, edit, and delete styles when a component changes:
app/
components/
Button.ts # Component and its styles
Card.ts
Header.ts
styles/
global.css # Global styles (served as static files)
reset.cssUse CSS Custom Properties for Consistency
Define design tokens as CSS custom properties at the root level. Reference them in your css() calls. This makes theme changes easy and keeps your values consistent:
/* public/styles/global.css */
:root {
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
}let card = css({
fontFamily: 'var(--font-sans)',
padding: 'var(--space-4)',
marginBottom: 'var(--space-6)',
})Minimize Animation Usage
Animations improve the user experience when used sparingly. Overusing them makes your application feel sluggish:
- Use animations for state changes the user initiated (opening a menu, submitting a form).
- Avoid animating large numbers of elements simultaneously.
- Keep durations short (200--400ms for most interactions).
- Respect
prefers-reduced-motion:
let fadeIn = css({
animation: 'fadeIn 300ms ease-out',
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
})Related
- Tutorial: Pages -- Building pages with HTML templates
- Security Guide -- Content Security Policy for styles