Comparison
Here ECSS is compared with popular approaches to styling components using a single example — a button with style variants. ECSS's main difference: the logic of "which style to apply" lives in the stylesheet, not in the component, and is fully typed at the same time.
CSS Modules
CSS Modules solve the problem of global names, but the state logic stays in the component. Every new variant means changes in two places: the CSS file and the JS component.
/* Button.module.css */
.button {
display: inline-flex;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
}
.primary {
background: #646cff;
color: #fff;
}
.danger {
background: #e53e3e;
color: #fff;
}
.ghost {
background: transparent;
border: 1px solid currentColor;
}
.disabled {
opacity: 0.4;
cursor: not-allowed;
}import styles from './Button.module.css';
function Button({ variant, disabled, children }) {
const className = [
styles.button,
variant === 'primary' && styles.primary,
variant === 'danger' && styles.danger,
variant === 'ghost' && styles.ghost,
disabled && styles.disabled,
]
.filter(Boolean)
.join(' ');
return <button className={className}>{children}</button>;
}With ECSS everything is declared in a single file, and the component knows nothing about any classes:
/* Button.ecss */
@enum Variant {
values: "primary", "danger", "ghost";
}
@block Button {
@param --variant Variant;
@param --disabled? boolean;
display: inline-flex;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
@if (--variant == "primary") {
background: #646cff;
color: #fff;
}
@if (--variant == "danger") {
background: #e53e3e;
color: #fff;
}
@if (--variant == "ghost") {
background: transparent;
border: 1px solid currentColor;
}
@if (--disabled) {
opacity: 0.4;
cursor: not-allowed;
}
}import { EButton } from './Button.ecss';
<EButton as="button" params={{ variant: 'primary' }}>
Button
</EButton>;- No need to assemble
classNameby hand - The state logic lives next to the styles
- TypeScript knows the allowed values of
variant— you can't pass a nonexistent one
styled-components
styled-components let you write styles directly in JS, using props for variants. However, the approach has several significant downsides: the CSS is generated at runtime on every render, the library itself adds tens of kilobytes to the bundle, and template strings with JS interpolations quickly turn into a hard-to-read mix of two languages.
import styled, { css } from 'styled-components';
const Button = styled.button<{
variant: 'primary' | 'danger' | 'ghost';
disabled?: boolean;
}>`
display: inline-flex;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
${({ variant }) =>
variant === 'primary' &&
css`
background: #646cff;
color: #fff;
`}
${({ variant }) =>
variant === 'danger' &&
css`
background: #e53e3e;
color: #fff;
`}
${({ variant }) =>
variant === 'ghost' &&
css`
background: transparent;
border: 1px solid currentColor;
`}
${({ disabled }) =>
disabled &&
css`
opacity: 0.4;
cursor: not-allowed;
`}
`;ECSS is processed at build time — plain static CSS reaches the browser:
/* Button.ecss */
@enum Variant {
values: "primary", "danger", "ghost";
}
@block Button {
@param --variant Variant;
@param --disabled? boolean;
display: inline-flex;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
@if (--variant == "primary") {
background: #646cff;
color: #fff;
}
@if (--variant == "danger") {
background: #e53e3e;
color: #fff;
}
@if (--variant == "ghost") {
background: transparent;
border: 1px solid currentColor;
}
@if (--disabled) {
opacity: 0.4;
cursor: not-allowed;
}
}import { EButton } from './Button.ecss';
<EButton as="button" params={{ variant: 'primary' }}>
Button
</EButton>;- No runtime overhead: the CSS is generated once at build time
- Works with SSR without additional setup
- Styles don't mix with JS logic