Styling Components in Coherent.js
This comprehensive guide covers all aspects of styling components in Coherent.js, from basic CSS classes to external CSS files, advanced theming systems and CSS-in-JS patterns.
🎨 Basic Styling Approaches
1. CSS Classes
The most straightforward way to style components:
const Button = ({ variant = 'primary', size = 'medium', children }) => ({
button: {
className: `btn btn--${variant} btn--${size}`,
children: Array.isArray(children) ? children : [children]
}
});
// Corresponding CSS
const styles = `
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.btn--primary {
background-color: #007bff;
color: white;
}
.btn--primary:hover {
background-color: #0056b3;
}
.btn--secondary {
background-color: #6c757d;
color: white;
}
.btn--small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn--large {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
}
`;2. Inline Styles
For dynamic or component-specific styling. style takes a string, or an object whose camelCase keys are written as CSS properties ({ backgroundColor: 'red' } renders style="background-color: red"):
const ProgressBar = ({ progress = 0, color = '#007bff', height = '20px' }) => ({
div: {
style: `
width: 100%;
height: ${height};
background-color: #e9ecef;
border-radius: 0.25rem;
overflow: hidden;
`,
children: [
{
div: {
style: `
width: ${Math.min(Math.max(progress, 0), 100)}%;
height: 100%;
background-color: ${color};
transition: width 0.3s ease;
`
}
}
]
}
});
// Usage
const progressBar = ProgressBar({
progress: 75,
color: '#28a745',
height: '10px'
});3. Conditional Styling
className accepts a string, an array (falsy entries are skipped) or an object (keys whose value is truthy are kept):
const Alert = ({ type = 'info', message, dismissible = false }) => ({
div: {
className: ['alert', `alert-${type}`, { 'alert-dismissible': dismissible }],
role: 'alert',
children: [
{ span: { text: message } },
dismissible && {
button: {
className: 'alert-close',
'aria-label': 'Close',
onClick: (event) => event.target.closest('.alert').remove(), // attached by hydrate()
children: [{ span: { text: '×' } }]
}
}
]
}
});
render(Alert({ type: 'success', message: 'Saved', dismissible: true }));
// <div class="alert alert-success alert-dismissible" role="alert">...📁 Stylesheets
Coherent.js renders HTML; stylesheets are ordinary elements in your document's head.
Linking CSS Files
import { render } from '@coherent.js/core';
const Document = ({ title, children }) => ({
html: {
children: [
{ head: {
children: [
{ title: { text: title } },
{ link: { rel: 'stylesheet', href: '/styles/main.css' } },
{ link: { rel: 'stylesheet', href: '/styles/components.css' } },
{ link: { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Inter&display=swap' } }
]
}},
{ body: { children } }
]
}
});
const html = `<!DOCTYPE html>${render(Document({ title: 'My App', children: [App()] }))}`;Serve the CSS files as static assets (for example with express.static), organised however you like:
/styles/
├── main.css // Global styles
├── components/
│ ├── button.css
│ └── form.css
└── themes/
├── light.css
└── dark.cssInline <style> Elements
{ style: { text: `.custom-override { color: #333; font-family: 'Inter', sans-serif; }` } }The text of a <style> element is not HTML-escaped (selectors such as a > b keep working), but a </style sequence inside it is neutralised so the CSS cannot close the element.
Scoped CSS
Pass scoped: true (alias encapsulate) to render() to scope a component's own <style> rules to its elements, similar to Angular's view encapsulation:
const Card = () => ({
div: {
className: 'card',
children: [
{ style: { text: '.card { padding: 1rem; } @media (max-width: 600px) { .card { padding: 0; } }' } },
{ h2: { className: 'title', text: 'Hello' } }
]
}
});
render(Card(), { scoped: true });
// <div class="card" coh-1ltr1dj=""><style coh-1ltr1dj="">.card[coh-1ltr1dj] { padding: 1rem; }
// @media (max-width: 600px) { .card[coh-1ltr1dj] { padding: 0; } }</style>
// <h2 class="title" coh-1ltr1dj="">Hello</h2></div>- Scoping is off by default.
- The
coh-…id is derived from the component's CSS, so the same component renders the same HTML every time (safe for HTML caching and hydration). - Rules inside
@media,@supports,@containerand@layerare scoped;@keyframes,@font-faceand other at-rules are left intact.
🎭 CSS-in-JS Patterns
Style Object Creation
const createStyles = (theme) => ({
container: {
display: 'flex',
flexDirection: 'column',
padding: theme.spacing.medium,
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius,
boxShadow: theme.shadows.medium
},
header: {
fontSize: theme.fonts.sizes.large,
fontWeight: theme.fonts.weights.bold,
color: theme.colors.primary,
marginBottom: theme.spacing.small
},
body: {
fontSize: theme.fonts.sizes.medium,
lineHeight: theme.fonts.lineHeights.normal,
color: theme.colors.text
},
footer: {
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing.small,
marginTop: theme.spacing.medium,
paddingTop: theme.spacing.small,
borderTop: `1px solid ${theme.colors.border}`
}
});
// `style` accepts these objects directly; this helper is only needed when you
// want to combine them with a CSS string
const stylesToString = (styles) => {
return Object.entries(styles)
.map(([property, value]) => {
const cssProperty = property.replace(/([A-Z])/g, '-$1').toLowerCase();
return `${cssProperty}: ${value}`;
})
.join('; ');
};
const Card = ({ title, content, actions, theme }) => {
const styles = createStyles(theme);
return {
div: {
style: styles.container,
children: [
title && {
h2: {
style: styles.header,
text: title
}
},
content && {
div: {
style: styles.body,
children: Array.isArray(content) ? content : [content]
}
},
actions && {
div: {
style: styles.footer,
children: Array.isArray(actions) ? actions : [actions]
}
}
]
}
};
};Dynamic Style Generation
const generateButtonStyles = ({ variant, size, disabled, rounded }) => {
const baseStyles = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
fontWeight: '500',
textDecoration: 'none'
};
// Size variations
const sizeStyles = {
small: {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem'
},
medium: {
padding: '0.5rem 1rem',
fontSize: '1rem'
},
large: {
padding: '0.75rem 1.5rem',
fontSize: '1.125rem'
}
};
// Variant styles
const variantStyles = {
primary: {
backgroundColor: disabled ? '#6c757d' : '#007bff',
color: 'white'
},
secondary: {
backgroundColor: disabled ? '#e9ecef' : 'transparent',
color: disabled ? '#6c757d' : '#007bff',
border: `1px solid ${disabled ? '#e9ecef' : '#007bff'}`
},
success: {
backgroundColor: disabled ? '#6c757d' : '#28a745',
color: 'white'
}
};
return {
...baseStyles,
...sizeStyles[size],
...variantStyles[variant],
borderRadius: rounded ? '2rem' : '0.25rem',
opacity: disabled ? 0.6 : 1
};
};
const DynamicButton = (props) => {
const {
children,
variant = 'primary',
size = 'medium',
disabled = false,
rounded = false,
...restProps
} = props;
const styles = generateButtonStyles({ variant, size, disabled, rounded });
return {
button: {
style: styles,
disabled,
...restProps,
children: Array.isArray(children) ? children : [children]
}
};
};🌈 Theming System
Theme Definition
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const deepMerge = (base, overrides) => Object.fromEntries(
[...new Set([...Object.keys(base), ...Object.keys(overrides)])].map((key) => [
key,
isObject(base[key]) && isObject(overrides[key]) ? deepMerge(base[key], overrides[key]) : overrides[key] ?? base[key]
])
);
const createTheme = (overrides = {}) => {
const baseTheme = {
colors: {
primary: '#007bff',
secondary: '#6c757d',
success: '#28a745',
warning: '#ffc107',
error: '#dc3545',
info: '#17a2b8',
light: '#f8f9fa',
dark: '#343a40',
white: '#ffffff',
black: '#000000',
// Semantic colors
background: '#ffffff',
surface: '#f8f9fa',
text: '#212529',
textSecondary: '#6c757d',
border: '#dee2e6',
shadow: 'rgba(0, 0, 0, 0.1)'
},
spacing: {
xs: '0.25rem',
small: '0.5rem',
medium: '1rem',
large: '1.5rem',
xl: '2rem',
xxl: '3rem'
},
fonts: {
family: {
body: 'system-ui, -apple-system, sans-serif',
heading: 'system-ui, -apple-system, sans-serif',
mono: 'SFMono-Regular, Consolas, monospace'
},
sizes: {
xs: '0.75rem',
small: '0.875rem',
medium: '1rem',
large: '1.25rem',
xl: '1.5rem',
xxl: '2rem'
},
weights: {
light: '300',
normal: '400',
medium: '500',
bold: '700'
},
lineHeights: {
tight: 1.2,
normal: 1.5,
relaxed: 1.75
}
},
shadows: {
small: '0 1px 3px rgba(0, 0, 0, 0.1)',
medium: '0 4px 6px rgba(0, 0, 0, 0.1)',
large: '0 10px 25px rgba(0, 0, 0, 0.1)'
},
borderRadius: '0.25rem',
breakpoints: {
mobile: '480px',
tablet: '768px',
desktop: '1024px',
wide: '1200px'
},
transitions: {
fast: '0.1s ease',
normal: '0.2s ease',
slow: '0.3s ease'
}
};
// Deep merge with overrides
return deepMerge(baseTheme, overrides);
};
// Dark theme variant
const darkTheme = createTheme({
colors: {
background: '#1a1a1a',
surface: '#2d2d2d',
text: '#ffffff',
textSecondary: '#a0a0a0',
border: '#404040'
}
});
// High contrast theme
const highContrastTheme = createTheme({
colors: {
primary: '#0000ff',
text: '#000000',
background: '#ffffff',
border: '#000000'
}
});Theme Provider
On the server, share the theme through the SSR context API of @coherent.js/state instead of threading it through every component:
import { render } from '@coherent.js/core';
import { createContextProvider, useContext, runWithContext } from '@coherent.js/state';
const themes = { light: createTheme(), dark: darkTheme, highContrast: highContrastTheme };
const ThemedButton = () => {
const theme = useContext('theme');
return {
button: {
style: { background: theme.colors.primary, color: theme.colors.white, borderRadius: theme.borderRadius },
text: 'Save'
}
};
};
app.get('/', (req, res) => runWithContext(() => {
const theme = themes[req.query.theme] ?? themes.light;
res.send(render({
div: {
'data-theme': req.query.theme ?? 'light',
children: [createContextProvider('theme', theme, ThemedButton)]
}
}));
}));runWithContext() keeps each request's context separate. Switching themes in the browser is usually easiest with CSS custom properties (see CSS Variable Generation) and a data-theme attribute on <html>.
Theme-Aware Components
const ThemedCard = ({ theme, title, content, variant = 'default' }) => {
const cardStyles = {
default: {
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border
},
primary: {
backgroundColor: theme.colors.primary,
borderColor: theme.colors.primary,
color: theme.colors.white
},
success: {
backgroundColor: theme.colors.success,
borderColor: theme.colors.success,
color: theme.colors.white
}
};
const selectedStyles = cardStyles[variant];
return {
div: {
style: `
background-color: ${selectedStyles.backgroundColor};
border: 1px solid ${selectedStyles.borderColor};
border-radius: ${theme.borderRadius};
padding: ${theme.spacing.medium};
box-shadow: ${theme.shadows.medium};
color: ${selectedStyles.color || theme.colors.text};
font-family: ${theme.fonts.family.body};
`,
children: [
title && {
h3: {
style: `
margin: 0 0 ${theme.spacing.small} 0;
font-size: ${theme.fonts.sizes.large};
font-weight: ${theme.fonts.weights.bold};
`,
text: title
}
},
content && {
div: {
style: `
font-size: ${theme.fonts.sizes.medium};
line-height: ${theme.fonts.lineHeights.normal};
`,
children: Array.isArray(content) ? content : [content]
}
}
]
}
};
};📱 Responsive Styling
Media Query Utilities
Media queries cannot go in a style attribute; put them in a <style> element, and render with scoped: true to keep the rules local to the component:
const mediaQueries = (theme) => ({
mobile: `@media (max-width: ${theme.breakpoints.mobile})`,
tablet: `@media (max-width: ${theme.breakpoints.tablet})`
});
const ResponsiveGrid = ({ theme, items, columns = { mobile: 1, tablet: 2, desktop: 3 } }) => {
const mq = mediaQueries(theme);
return {
div: {
className: 'grid',
children: [
{ style: { text: `
.grid { display: grid; gap: ${theme.spacing.medium}; grid-template-columns: repeat(${columns.desktop}, 1fr); }
${mq.tablet} { .grid { grid-template-columns: repeat(${columns.tablet}, 1fr); } }
${mq.mobile} { .grid { grid-template-columns: repeat(${columns.mobile}, 1fr); } }
.grid-item { background: ${theme.colors.surface}; padding: ${theme.spacing.medium}; border-radius: ${theme.borderRadius}; }
` } },
...items.map((item) => ({ div: { className: 'grid-item', children: [item] } }))
]
}
};
};
render(ResponsiveGrid({ theme: createTheme(), items }), { scoped: true });Responsive Typography
const ResponsiveText = ({
theme,
children,
variant = 'body',
responsive = true
}) => {
const typographyStyles = {
h1: {
fontSize: responsive ?
`clamp(${theme.fonts.sizes.xl}, 5vw, ${theme.fonts.sizes.xxl})` :
theme.fonts.sizes.xxl,
fontWeight: theme.fonts.weights.bold,
lineHeight: theme.fonts.lineHeights.tight
},
h2: {
fontSize: responsive ?
`clamp(${theme.fonts.sizes.large}, 4vw, ${theme.fonts.sizes.xl})` :
theme.fonts.sizes.xl,
fontWeight: theme.fonts.weights.bold,
lineHeight: theme.fonts.lineHeights.tight
},
body: {
fontSize: theme.fonts.sizes.medium,
fontWeight: theme.fonts.weights.normal,
lineHeight: theme.fonts.lineHeights.normal
},
small: {
fontSize: theme.fonts.sizes.small,
fontWeight: theme.fonts.weights.normal,
lineHeight: theme.fonts.lineHeights.normal
}
};
const styles = typographyStyles[variant];
return {
span: {
style: {
...styles,
fontFamily: theme.fonts.family.body,
color: theme.colors.text
},
children: Array.isArray(children) ? children : [children]
}
};
};🎯 Animation and Transitions
CSS Transitions
const AnimatedButton = ({ theme, children, loading = false, ...props }) => {
const buttonStyles = {
padding: theme.spacing.medium,
backgroundColor: loading ? theme.colors.secondary : theme.colors.primary,
color: theme.colors.white,
border: 'none',
borderRadius: theme.borderRadius,
cursor: loading ? 'wait' : 'pointer',
transition: `all ${theme.transitions.normal}`,
transform: loading ? 'scale(0.98)' : 'scale(1)',
opacity: loading ? 0.8 : 1,
position: 'relative',
overflow: 'hidden'
};
return {
button: {
style: buttonStyles,
disabled: loading,
...props,
children: [
loading && {
span: {
style: `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
`
}
},
{
span: {
style: `opacity: ${loading ? 0 : 1}; transition: opacity ${theme.transitions.fast};`,
children: Array.isArray(children) ? children : [children]
}
}
]
}
};
};CSS Animations
const generateKeyframes = () => `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`;
// Include the keyframes once per page: { style: { text: generateKeyframes() } }
const AnimatedCard = ({ theme, animation = 'fadeIn', delay = 0, children }) => {
const animationStyles = {
fadeIn: `fadeIn 0.5s ease ${delay}s both`,
slideIn: `slideIn 0.3s ease ${delay}s both`,
pulse: `pulse 2s ease-in-out ${delay}s infinite`
};
return {
div: {
style: `
animation: ${animationStyles[animation]};
background: ${theme.colors.surface};
padding: ${theme.spacing.medium};
border-radius: ${theme.borderRadius};
border: 1px solid ${theme.colors.border};
`,
children: Array.isArray(children) ? children : [children]
}
};
};🛠️ Utility Functions
Style Merging
const mergeStyles = (...styleSets) => {
return styleSets.reduce((merged, styles) => {
if (!styles) return merged;
if (typeof styles === 'string') {
return `${merged}; ${styles}`;
}
if (typeof styles === 'object') {
return { ...merged, ...styles };
}
return merged;
}, {});
};
// Usage
const CombinedButton = ({ theme, variant, size, customStyles, ...props }) => {
const baseStyles = generateButtonStyles({ variant, size });
const finalStyles = mergeStyles(baseStyles, customStyles);
return {
button: {
style: typeof finalStyles === 'object' ?
stylesToString(finalStyles) :
finalStyles,
...props
}
};
};CSS Variable Generation
const generateCSSVariables = (theme, prefix = '--') => {
const flatten = (obj, parentKey = '') => {
let result = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = parentKey ? `${parentKey}-${key}` : key;
if (typeof value === 'object' && value !== null) {
result = { ...result, ...flatten(value, newKey) };
} else {
result[`${prefix}${newKey}`] = value;
}
}
return result;
};
return flatten(theme);
};
const ThemeVariables = ({ theme }) => {
const variables = generateCSSVariables(theme);
const cssVariables = Object.entries(variables)
.map(([key, value]) => `${key}: ${value}`)
.join('; ');
return {
style: {
text: `:root { ${cssVariables} }`
}
};
};📚 Best Practices
1. Consistent Design System
// ✅ Good - Use design tokens
const Button = ({ theme, variant }) => ({
button: {
style: {
padding: theme.spacing.medium,
fontSize: theme.fonts.sizes.medium,
borderRadius: theme.borderRadius,
backgroundColor: theme.colors[variant]
}
}
});
// ❌ Avoid - Magic numbers
const MagicButton = ({ variant }) => ({
button: {
style: {
padding: '12px 24px',
fontSize: '14px',
borderRadius: '4px',
backgroundColor: variant === 'primary' ? '#007bff' : '#6c757d'
}
}
});2. Performance Optimization
// ✅ Good - Reuse style objects
const buttonStyles = {
base: { padding: '0.5rem 1rem', border: 'none' },
primary: { backgroundColor: '#007bff', color: 'white' }
};
// ❌ Avoid - Recreating styles
const Button = () => ({
button: {
style: {
padding: '0.5rem 1rem', // Recreated every render
border: 'none'
}
}
});3. Accessibility
Focus styles need a pseudo-class, so they belong in a stylesheet rather than a style attribute:
const AccessibleButton = ({ children, ...props }) => ({
button: {
className: 'btn',
...props,
children
}
});
// In your stylesheet (or a { style: { text } } element)
const focusStyles = `
.btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
`;4. Maintainable CSS
// ✅ Good - Semantic naming
const semanticStyles = {
cardContainer: { /* styles */ },
cardHeader: { /* styles */ },
cardBody: { /* styles */ }
};
// ❌ Avoid - Presentational naming
const presentationalStyles = {
blueBox: { /* styles */ },
bigText: { /* styles */ },
redBorder: { /* styles */ }
};This comprehensive styling guide provides all the tools and patterns needed to create beautiful, maintainable, and accessible designs in Coherent.js applications.