• Getting Started
    • Overview
    • Why Without JSX?
    • Installation
    • Usage
    • Styling
    • Theming
    • Portal System
    • Rules & Patterns
    • Framework Integration
    • Compiler
    • FAQ
    • Release Notes
  • MUI Integration
  • Components
  • Hooks

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 nodeflex-shrink
not a flex container0
display: flex, no overflow and no wrapping0
display: flex with overflow handlednot set
display: flex with flexWrapnot set
any node setting flexShrink itselfyours, untouched
any node setting flexyour 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 flex shorthand keeps its own shrink factor. Before, only a number and none/auto/initial were read, so flex: '1 1 auto' got flex-shrink: 0. flex: 0 now shrinks, as CSS defines it (0 1 0%): write flex: '0 0 0%' or add flexShrink: 0 to 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 css replaced the factory's whole css, 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

ApproachUse 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 as target (as: MyComponent). Use Node(MyComponent, …) or that component's own factory instead.
  • Not on no-style tags. Script, Style and the other tags that skip the styling pipeline do not take as; 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.

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 MeoTheme augmentation
  • Framework Integration — Next.js and Vite setup, including SSR and the build-time compiler
  • FAQ — Common patterns and edge cases

On this page