Styling Guide — MeoNode UI
Type-safe, theme-aware CSS-in-JS powered by @emotion/react. Apply CSS as props, or use the css prop for pseudo-classes, media queries, keyframes, and nested selectors.
Fundamentals
CSS properties pass directly as props on any node:
import { Button } from '@meonode/ui' Button('Click Me', { backgroundColor: 'tomato', padding: '12px 24px', borderRadius: 8, color: 'white', cursor: 'pointer', })
For anything beyond plain properties — selectors, queries, animations — use the css prop:
import { Div } from '@meonode/ui' Div({ padding: 20, css: { '&:hover': { transform: 'scale(1.05)' }, '@media (max-width: 768px)': { padding: 12 }, }, })
Numbers become pixels on properties that take a length — padding: 20 emits
padding: 20px. Properties that are unitless by nature are left alone, so
zIndex: 10, lineHeight: 2, fontWeight: 600 and flexGrow: 3 emit exactly
that. You never need a string to avoid the px, and reaching for one causes the
opposite problem: padding: '8' is a string, gets no unit, and is dropped by the
browser as an invalid length — development warns when it sees one.
Vendor prefixing, dedup, and SSR critical-CSS extraction happen automatically through Emotion.
css also takes the other shapes Emotion accepts, and each applies after the
direct CSS props, so css wins a conflict:
import { css } from '@emotion/react' import { Div } from '@meonode/ui' Div({ padding: 8, css: [{ margin: 4 }, { '&:hover': { color: 'red' } }] }) // an array, applied in order Div({ padding: 8, css: css({ margin: 4 }) }) // an Emotion css() result Div({ css: 'margin: 4px; &:hover { color: red; }' }) // a string Div({ padding: 8, css: isActive && { margin: 4 } }) // false or null adds nothing
A number or a component selector is not a style on its own, so the css prop
does not accept one at the top level; both still work inside a style object.
Since 3.1.0. Before, only a style object rendered: an array produced selectors that never matched, a string one declaration per character, and a
css()result dropped the direct CSS props beside it.
Three declarations you did not write
Every styled node is emitted with min-height: 0 and min-width: 0, and usually
flex-shrink: 0:
.css-1abc2de { flex-shrink: 0; min-height: 0; min-width: 0; /* your styles */ }
This exists to fix one specific problem. A flex item defaults to min-width: auto, which means it cannot shrink below its content — so a scrollable panel
inside a flex row refuses to scroll, and text refuses to truncate. Setting both
to 0 restores the behaviour almost everyone expects.
flex-shrink: 0 is applied more selectively, and is dropped when it would be
wrong:
| The node | flex-shrink |
|---|---|
| not a flex container | 0 |
display: flex, no overflow and no wrapping | 0 |
display: flex with overflow handled | not set |
display: flex with flexWrap | not set |
any node setting flexShrink itself | yours, untouched |
any node setting flex | your shorthand's shrink factor — flex: '1 1 auto' and flex: 0 shrink, flex: 'none' does not; not set if the value cannot be read, such as var(--flex) |
So a node you expected to shrink and which will not is usually this, and the fix
is to say flexShrink: 1 — an explicit value is never overridden.
Since 3.1.0 the
flexshorthand keeps its own shrink factor. Before, only a number andnone/auto/initialwere read, soflex: '1 1 auto'gotflex-shrink: 0.flex: 0now shrinks, as CSS defines it (0 1 0%): writeflex: '0 0 0%'or addflexShrink: 0to keep such an item from shrinking.
Pseudo-Classes & Pseudo-Elements
Use selectors inside css. The & token refers to the current node.
import { Button } from '@meonode/ui' Button('Hover Me', { padding: '14px 28px', backgroundColor: '#3B82F6', color: 'white', borderRadius: 10, transition: 'all 0.2s ease', css: { '&:hover': { backgroundColor: '#2563EB', transform: 'translateY(-2px)' }, '&:active': { transform: 'translateY(0)' }, '&:focus-visible': { outline: '2px solid #1D4ED8', outlineOffset: 2 }, '&:disabled': { backgroundColor: '#9CA3AF', cursor: 'not-allowed' }, '&::before': { content: '"→ "', marginRight: 4, }, }, })
Structural pseudo-classes (:first-of-type, :nth-of-type, ::placeholder, etc.) work the same way.
Media Queries
Standard at-rules go in css:
import { Column } from '@meonode/ui' Column({ padding: '40px 20px', fontSize: 16, css: { '@media (min-width: 768px)': { padding: '60px 40px', fontSize: 18 }, '@media (min-width: 1024px)': { padding: '80px 60px', fontSize: 20 }, // User preference queries also work '@media (prefers-color-scheme: dark)': { backgroundColor: '#0F172A', color: '#F1F5F9', }, '@media (prefers-reduced-motion: reduce)': { transition: 'none', }, }, })
Keyframe Animations
Define @keyframes inside css and reference the name in animation:
import { Div } from '@meonode/ui' const Spinner = Div({ width: 60, height: 60, border: '4px solid #E5E7EB', borderTop: '4px solid #3B82F6', borderRadius: '50%', css: { '@keyframes spin': { '0%': { transform: 'rotate(0deg)' }, '100%': { transform: 'rotate(360deg)' }, }, animation: 'spin 1s linear infinite', }, })
For staggered effects, set animationDelay per element. The index is the right
key here and one of the few places it is: the rows are positional by nature and
the list never reorders, grows or shrinks. See
Lists and keys for
lists built from data, where an index key reintroduces the bug a key is for.
import { Row, Div } from '@meonode/ui' const WaveLoader = Row({ gap: 8, children: Array.from({ length: 5 }, (_, i) => Div({ key: i, width: 20, height: 60, backgroundColor: '#3B82F6', borderRadius: 4, css: { '@keyframes wave': { '0%, 40%, 100%': { transform: 'scaleY(0.4)' }, '20%': { transform: 'scaleY(1)' }, }, animation: 'wave 1.2s ease-in-out infinite', animationDelay: `${i * 0.1}s`, }, }), ), })
Nested & Attribute Selectors
import { Column } from '@meonode/ui' Column({ padding: 32, borderRadius: 16, css: { // Direct children '& > *': { marginBottom: 20 }, '& > *:last-child': { marginBottom: 0 }, // All buttons inside '& button': { fontWeight: 600 }, '& button[data-variant="primary"]': { backgroundColor: '#3B82F6', color: 'white', }, // Adjacent sibling '& h3 + p': { marginTop: 8, color: '#4B5563' }, }, })
CSS Variables
Define custom properties in css and reference them with var(). Useful for runtime theme swapping without re-rendering subtree styles:
import { Div, Button, Component } from '@meonode/ui' import { useState } from 'react' const themes = { blue: { primary: '#3B82F6', accent: '#93C5FD' }, green: { primary: '#10B981', accent: '#6EE7B7' }, } const ThemedCard = Component(function ThemedCard() { const [theme, setTheme] = useState<keyof typeof themes>('blue') return Div({ padding: 32, borderRadius: 16, css: { '--primary': themes[theme].primary, '--accent': themes[theme].accent, backgroundColor: 'var(--primary)', color: 'white', '& button': { backgroundColor: 'var(--accent)', color: 'white', padding: '8px 16px', borderRadius: 8, }, }, children: Object.keys(themes).map(name => Button(name, { key: name, onClick: () => setTheme(name as keyof typeof themes), }), ), }) })
For static design tokens, prefer the theming system over raw CSS variables.
Reusable Styled Components
Use createNode (props-first) or createChildrenFirstNode (children-first) to bake in default styles:
import { createNode } from '@meonode/ui' const Card = createNode('div', { padding: 24, backgroundColor: '#FFFFFF', borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', css: { '&:hover': { transform: 'translateY(-4px)' }, }, }) // Override defaults at the call site Card({ backgroundColor: '#F3F4F6', children: 'Card content' })
A call-site css extends the factory's rather than replacing it. Two style
objects merge key by key, nested selectors and media queries included, and the
call site wins a conflict. Any other shape — an array, a string, a css()
result — applies after the factory's. To remove factory styles, set one key to
undefined or the whole css to false:
Card({ css: { margin: 4 } }) // keeps the factory's :hover Card({ css: { '&:hover': undefined } }) // drops only the :hover rule Card({ css: false }) // drops the factory's css
Since 3.1.0. Before, a call-site
cssreplaced the factory's wholecss, so adding one rule lost every selector and media query the factory defined.
Element Polymorphism (as)
Swap the rendered HTML tag while keeping the node's styles and Emotion class — similar to Emotion's as prop. Useful when a layout factory should occasionally render as an anchor, span, or other intrinsic element.
import { Div, P, Node } from '@meonode/ui' // Styled block that renders as a link Div({ as: 'a', href: '/home', backgroundColor: 'theme.primary', color: 'theme.primary.content', padding: '12px 24px', borderRadius: 8, children: 'Go home', }) // Children-first factories accept `as` in the props object P('Badge', { as: 'span', fontSize: 12, color: 'theme.primary' }) // Low-level Node() factory Node('div', { as: 'a', href: '#', color: 'blue', children: 'Link' })
Works on Node(), createNode(), and createChildrenFirstNode(). TypeScript narrows DOM props and event handlers to the resolved tag — href is valid when as: 'a', and onClick types e.currentTarget as HTMLAnchorElement.
When to use as vs a dedicated factory
| Approach | Use when |
|---|---|
Div({ as: 'a', … }) | You already have styled defaults on a factory and only need a different tag occasionally |
createNode('a', { … }) | Links (or other tags) are a first-class reusable component in your design system |
as is stripped at render time and never appears as a DOM attribute.
What as will and will not do
- Intrinsic tags only. TypeScript rejects a custom React component as an
astarget (as: MyComponent). UseNode(MyComponent, …)or that component's own factory instead. - Not on no-style tags.
Script,Styleand the other tags that skip the styling pipeline do not takeas;Script({ as: 'div' })is a type error. - RSC-safe — in a server component the swapped element is styled on the server and its rule ships with the page, so the served HTML is styled and hydration matches it.
Related FAQ
How does styling work in MeoNode UI?
Styling is prop-driven and theme-aware. Use direct CSS props for straightforward styling, and use the css prop for selectors, media queries, keyframes, and nested rules.
How do I pass props like height to custom components?
If a prop name overlaps with CSS properties but should be treated as component logic, place it under props so it is forwarded without being interpreted as style.
How does the theming system work?
Use ThemeProvider and token paths like theme.primary so style values stay centralized and consistent across components.
How do I render a styled node as a different HTML tag?
Use the as prop on any styled factory — for example Div({ as: 'a', href: '…', … }). See Element Polymorphism (as) above.
More details: /docs/getting-started/faq
Next Steps
- Theming — Design tokens and the
MeoThemeaugmentation - Framework Integration — Next.js and Vite setup, including SSR and the build-time compiler
- FAQ — Common patterns and edge cases
On this page
- Styling Guide — MeoNode UI