Skip to main content

Styling Components

Remix provides a powerful css prop for styling components with support for pseudo-selectors, pseudo-elements, nested rules, and media queries.

Basic CSS Prop

Use the css prop to apply inline styles:
function Button() {
  return () => (
    <button
      css={{
        color: 'white',
        backgroundColor: 'blue',
        padding: '12px 24px',
        borderRadius: '4px',
        border: 'none',
        cursor: 'pointer',
      }}
    >
      Click me
    </button>
  )
}

CSS Prop vs Style Prop

The css prop creates static CSS rules, while style applies styles directly to the element. Use css prop for:
  • Static styles that don’t change
  • Pseudo-selectors (:hover, :focus, etc.)
  • Media queries
Use style prop for:
  • Dynamic styles that change based on state or props
  • Computed values that update frequently
// Good: Static in css, dynamic in style
function ProgressBar(handle: Handle) {
  let progress = 0

  return () => (
    <div
      css={{
        backgroundColor: 'blue', // Static
        height: '20px',
        borderRadius: '4px',
      }}
      style={{
        width: `${progress}%`, // Dynamic
      }}
    >
      {progress}%
    </div>
  )
}

// Bad: Dynamic values in css prop
function ProgressBar(handle: Handle) {
  let progress = 0

  return () => (
    <div
      css={{
        width: `${progress}%`, // Creates new CSS rule on every update
        backgroundColor: 'blue',
      }}
    >
      {progress}%
    </div>
  )
}

Pseudo-Selectors

Use & to reference the current element:
function Button() {
  return () => (
    <button
      css={{
        color: 'white',
        backgroundColor: 'blue',
        padding: '12px 24px',
        borderRadius: '4px',
        border: 'none',
        cursor: 'pointer',
        '&:hover': {
          backgroundColor: 'darkblue',
          transform: 'translateY(-1px)',
        },
        '&:active': {
          backgroundColor: 'navy',
          transform: 'translateY(0)',
        },
        '&:focus': {
          outline: '2px solid yellow',
          outlineOffset: '2px',
        },
        '&:disabled': {
          opacity: 0.5,
          cursor: 'not-allowed',
        },
      }}
    >
      Click me
    </button>
  )
}

Pseudo-Elements

Use &::before and &::after:
function Badge() {
  return (props: { count: number }) => (
    <div
      css={{
        position: 'relative',
        display: 'inline-block',
        '&::before': {
          content: '""',
          position: 'absolute',
          top: '-4px',
          right: '-4px',
          width: '8px',
          height: '8px',
          backgroundColor: 'red',
          borderRadius: '50%',
        },
      }}
    >
      {props.count > 0 && <span>{props.count}</span>}
    </div>
  )
}

Attribute Selectors

Use &[attribute] for attribute selectors:
function Input() {
  return (props: { required?: boolean }) => (
    <input
      required={props.required}
      css={{
        padding: '8px',
        border: '1px solid #ccc',
        borderRadius: '4px',
        '&[required]': {
          borderColor: 'red',
        },
        '&[aria-invalid="true"]': {
          borderColor: 'red',
          outline: '2px solid red',
        },
      }}
    />
  )
}

Descendant Selectors

Style child elements from the parent:
import type { RemixNode } from 'remix/component'

function Card() {
  return (props: { children: RemixNode }) => (
    <div
      css={{
        padding: '20px',
        border: '1px solid #ddd',
        borderRadius: '8px',
        backgroundColor: 'white',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
        // Style descendants
        '& h2': {
          marginTop: 0,
          fontSize: '24px',
          fontWeight: 'bold',
        },
        '& p': {
          color: '#666',
          lineHeight: 1.6,
        },
        '& .icon': {
          width: '24px',
          height: '24px',
          marginRight: '8px',
        },
        '& button': {
          marginTop: '16px',
        },
      }}
    >
      {props.children}
    </div>
  )
}

Nested Selectors for Parent State

Use nested selectors when parent state affects children. This is preferable to managing state in JavaScript:
// Good: CSS handles hover state
function Card() {
  return (props: { children: RemixNode }) => (
    <div
      css={{
        border: '1px solid #ddd',
        borderRadius: '8px',
        padding: '20px',
        '&:hover': {
          borderColor: 'blue',
          // Parent hover affects children
          '& .title': {
            color: 'blue',
          },
          '& .description': {
            opacity: 1,
          },
        },
        '& .title': {
          fontSize: '20px',
          fontWeight: 'bold',
          color: '#333',
        },
        '& .description': {
          opacity: 0.7,
          marginTop: '8px',
        },
      }}
    >
      <div className="title">Title</div>
      <div className="description">Description</div>
    </div>
  )
}

// Bad: Managing hover state in JavaScript
function Card(handle: Handle) {
  let isHovered = false

  return (props: { children: RemixNode }) => (
    <div
      mix={[
        on('mouseenter', () => {
          isHovered = true
          handle.update()
        }),
        on('mouseleave', () => {
          isHovered = false
          handle.update()
        }),
      ]}
      css={{
        border: `1px solid ${isHovered ? 'blue' : '#ddd'}`,
      }}
    >
      <div className="title" css={{ color: isHovered ? 'blue' : '#333' }}>
        Title
      </div>
    </div>
  )
}

Media Queries

Use @media for responsive design:
function ResponsiveGrid() {
  return (props: { children: RemixNode }) => (
    <div
      css={{
        display: 'grid',
        gap: '16px',
        gridTemplateColumns: '1fr',
        '@media (min-width: 768px)': {
          gridTemplateColumns: 'repeat(2, 1fr)',
        },
        '@media (min-width: 1024px)': {
          gridTemplateColumns: 'repeat(3, 1fr)',
        },
      }}
    >
      {props.children}
    </div>
  )
}

Comprehensive Example

A product card demonstrating all features:
function ProductCard() {
  return (props: { title: string; price: number; image: string }) => (
    <div
      css={{
        border: '1px solid #ddd',
        borderRadius: '8px',
        overflow: 'hidden',
        transition: 'transform 0.2s, box-shadow 0.2s',
        '&:hover': {
          transform: 'translateY(-4px)',
          boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
          '& .title': {
            color: 'blue',
          },
          '& button': {
            backgroundColor: 'darkblue',
          },
        },
        '@media (max-width: 768px)': {
          '&:hover': {
            transform: 'translateY(-2px)',
          },
        },
      }}
    >
      <img
        src={props.image}
        alt={props.title}
        css={{
          width: '100%',
          height: '200px',
          objectFit: 'cover',
          '@media (max-width: 768px)': {
            height: '150px',
          },
        }}
      />
      <div
        className="content"
        css={{
          padding: '16px',
          '@media (max-width: 768px)': {
            padding: '12px',
          },
        }}
      >
        <h3
          className="title"
          css={{
            fontSize: '18px',
            fontWeight: 'bold',
            marginTop: 0,
            marginBottom: '8px',
            transition: 'color 0.2s',
          }}
        >
          {props.title}
        </h3>
        <div
          className="price"
          css={{
            fontSize: '20px',
            color: 'green',
            fontWeight: 'bold',
          }}
        >
          ${props.price}
        </div>
        <button
          css={{
            width: '100%',
            padding: '12px',
            backgroundColor: 'blue',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            transition: 'background-color 0.2s',
            '&:active': {
              transform: 'scale(0.98)',
            },
          }}
        >
          Add to Cart
        </button>
      </div>
    </div>
  )
}

CSS Mixin

For complex styling logic, create reusable CSS mixins:
import { css } from 'remix/component'

function StyledButton() {
  return (props: { variant: 'primary' | 'secondary' }) => (
    <button
      mix={[
        css({
          padding: '12px 24px',
          borderRadius: '4px',
          border: 'none',
          cursor: 'pointer',
          transition: 'all 0.2s',
          '&:active': {
            transform: 'scale(0.98)',
          },
        }),
        props.variant === 'primary'
          ? css({
              backgroundColor: 'blue',
              color: 'white',
              '&:hover': {
                backgroundColor: 'darkblue',
              },
            })
          : css({
              backgroundColor: '#eee',
              color: '#333',
              '&:hover': {
                backgroundColor: '#ddd',
              },
            }),
      ]}
    >
      Click me
    </button>
  )
}

Next Steps

  • Events - Handle user interactions

Build docs developers (and LLMs) love