A CSS-in-JS solution for JavaScript/TypeScript projects.
- JavaScript to CSS conversion with an intuitive object syntax
- Build CSS from organized source files with a simple CLI
- CSS layering support for proper style encapsulation
- Media query and device/theme selectors with shorthands
- CSS variable sets for different themes and devices
npm install esm-stylesESM Styles lets you write and store styles in JavaScript this way:
// article.styles.mjs
import $device from './$device.mjs'
import $theme from './$theme.mjs'
import { card } from './card.styles.mjs'
export default {
article: {
display: 'flex',
card,
'@max-tablet': {
flexDirection: 'column',
},
button: {
borderRadius: $device.radii.md,
backgroundColor: $theme.paper.tinted,
'@dark': {
fontWeight: 300,
},
},
},
}monorepo/
├── package.json
└── packages/
├── app/
│ ├── package.json
│ └── src/
│ ├── css/
│ ├── styles.css
│ └── components/
│ ├── article.tsx
│ ├── button.tsx
│ └── card.tsx
└── styles/
├── $device.mjs
├── $theme.mjs
├── components/
│ ├── article.styles.mjs
│ ├── button.styles.mjs
│ └── card.styles.mjs
├── components.styles.mjs
├── esm-styles.config.js
└── package.json
Build your styles by creating a configuration file and running the CLI:
npx buildOr specify a custom config:
npx build path/to/config.jsWatch for changes:
npx watchCreate a esm-styles.config.js in your project root (or use a custom path):
export default {
basePath: './src/styles',
sourcePath: 'source',
outputPath: 'css',
sourceFilesSuffix: '.styles.mjs',
// Input floors (replaces layers)
floors: [
{ source: 'defaults', layer: 'defaults' },
{ source: 'components', layer: 'components' },
{ source: 'layout', layer: 'layout' },
],
// Specify which floors to include in main CSS
importFloors: ['defaults', 'components', 'layout'],
// Output
mainCssFile: 'styles.css',
// Global variables
globalVariables: 'global',
globalRootSelector: ':root',
// Media types and queries
media: {
theme: ['light', 'dark'],
device: ['mobile', 'tablet', 'desktop'],
},
mediaSelectors: {
theme: {
light: [
{
selector: '.light',
},
{
selector: '.auto',
mediaQuery: 'screen and (prefers-color-scheme: light)',
prefix: 'auto',
},
],
dark: [
{
selector: '.dark',
},
{
selector: '.auto',
mediaQuery: 'screen and (prefers-color-scheme: dark)',
prefix: 'auto',
},
],
},
// Device selectors
device: {
mobile: [
{
mediaQuery: 'screen and (max-width: 767px)',
},
],
tablet: [
{
mediaQuery: 'screen and (min-width: 768px) and (max-width: 1024px)',
},
],
desktop: [
{
mediaQuery: 'screen and (min-width: 1025px)',
},
],
},
},
// Media query shorthands (in addition to media.device and media.theme names)
mediaQueries: {
'min-tablet': '(min-width: 768px)',
'max-tablet': '(max-width: 1024px)',
hover: '(hover: hover)',
// ...whatever you want
},
// Import aliases (optional) - replace long relative paths with short prefixes
aliases: {
'@': '.', // resolves relative to sourcePath
},
}{
p: {
fontSize: '16px',
color: 'black',
a: {
color: 'blue'
},
strong: {
fontWeight: 'bold'
}
}
}Compiles to:
p {
font-size: 16px;
color: black;
}
p a {
color: blue;
}
p strong {
font-weight: bold;
}{
div: {
highlighted: {
// highlighted is not a tag
border: '1px solid red',
},
p: {
// p is a tag
fontSize: '16px',
},
_video: {
// video is a tag, but the class is meant, use single underscore prefix
aspectRatio: 1.77,
}
}
}Compiles to:
div.highlighted {
border: 1px solid red;
}
div p {
font-size: 16px;
}
div.video {
aspect-ratio: 1.77;
}{
modal: {
position: 'relative',
__close: {
position: 'absolute',
top: '10px',
right: '10px'
}
}
}Compiles to:
.modal {
position: relative;
}
.modal .close {
position: absolute;
top: 10px;
right: 10px;
}{
'button, .btn': {
padding: '10px 20px'
},
'input[type="text"], input[type="email"]': {
borderRadius: '4px'
}
}{
card: {
display: 'flex',
'@media (max-width: 768px)': {
flexDirection: 'column',
'@media (orientation: portrait)': {
padding: '10px'
}
}
}
}{
main: {
display: 'grid',
'@mobile': {
display: 'flex',
flexDirection: 'column'
},
'@desktop': {
gridTemplateColumns: 'repeat(3, 1fr)'
}
}
}{
card: {
backgroundColor: 'white',
color: 'black',
'@dark': {
backgroundColor: '#222',
color: 'white'
}
}
}Define variables in a global variables file:
// global.styles.mjs
export default {
colors: {
primary: '#4285f4',
secondary: '#34a853',
error: '#ea4335',
},
spacing: {
sm: '8px',
md: '16px',
lg: '24px',
},
}Define theme-specific variables:
// light.styles.mjs
export default {
paper: {
bright: '#ffffff',
tinted: '#f0f0f0',
},
ink: {
bright: '#000000',
faded: '#333333',
accent: '#ff0000',
},
}// dark.styles.mjs
export default {
paper: {
bright: '#000000',
tinted: '#323232',
},
ink: {
bright: '#ffffff',
faded: '#b3b3b3',
},
}Use with supporting modules:
// component.styles.mjs
import $theme from './$theme.mjs'
export default {
button: {
backgroundColor: $theme.paper.bright,
color: $theme.ink.bright,
padding: '10px 20px',
},
}Organize your styles in floors for better control over specificity and output:
// Configuration example
floors: [
{ source: 'defaults', layer: 'defaults' },
{ source: 'components', layer: 'components' },
{ source: 'layout', layer: 'layout' },
{ source: 'utilities' }, // No layer wrapper
{ source: 'overrides', outputPath: 'special' }, // Custom output path
]Each floor can:
- Be wrapped in a CSS layer (optional)
- Have a custom output path (optional)
- Be included or excluded from the main CSS file via
importFloors
The build process wraps floors in their respective layers and generates a main CSS file with proper import order.
Missing variables in one theme automatically inherit from the previous theme in the configuration.
Simplify imports in your style files by configuring path aliases:
// esm-styles.config.js
export default {
// ...
aliases: {
'@': '.', // @ resolves to sourcePath
'@components': './components',
},
}Then use in your style files:
// Before (relative paths)
import $theme from '../../$theme.mjs'
import { button } from '../components/button.styles.mjs'
// After (with aliases)
import $theme from '@/$theme.mjs'
import { button } from '@components/button.styles.mjs'Alias paths are resolved relative to the sourcePath directory. This feature uses esbuild internally for fast module resolution.
To enable Cmd+click navigation and IntelliSense for aliased imports, create a jsconfig.json in your styles source directory with matching path mappings:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}For humans: doc/usage-guide.md
For AI assistants: doc/ai-guide.md
API reference: doc/api-reference.md
MIT