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.