Skip to content

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

ts
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.css

Reference them in your HTML:

ts
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:

ts
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.

ts
import { css } from 'remix/component'

Basic Usage

Apply styles to elements using the mix attribute:

tsx
<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:

tsx
<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

tsx
<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:

tsx
<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

tsx
<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:

tsx
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:

ts
// 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:

tsx
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:

tsx
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:

tsx
// 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:

ts
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:

ts
import { tween, easings } from 'remix/component'

let tweenValue = tween(0, {
  duration: 300,               // Duration in milliseconds
  easing: easings.easeOutCubic, // Easing function
})

Available Easings

ts
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 end

Entrance Animations

Animate elements when they first appear:

ts
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:

ts
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:

ts
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

tsx
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.css

Use 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:

css
/* 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;
}
ts
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:
tsx
let fadeIn = css({
  animation: 'fadeIn 300ms ease-out',

  '@media (prefers-reduced-motion: reduce)': {
    animation: 'none',
  },
})

Released under the MIT License.