Coral UI

Component Variants

Learn how to create flexible, multi-variant components using Coral's CVA-style variant system.

Introduction

Coral's variant system allows you to define component-level variants with per-node style responses—similar to class-variance-authority (CVA) but built into the specification format.

Related: This guide covers variant concepts in depth. For API reference, see Component Variants API. For examples of variants in generated React components, see Coral to React.

Why Variants?

Variants are essential for building design systems. Instead of creating separate components for each visual style, you define a single component with configurable variations:

  • Intent variants: primary, secondary, destructive, ghost
  • Size variants: sm, md, lg, xl
  • State variants: default, error, success, warning

Basic Example

Here's a simple button with intent and size variants:

{
  "name": "Button",
  "elementType": "button",

  "componentVariants": {
    "axes": [
      {
        "name": "intent",
        "values": ["primary", "secondary", "destructive"],
        "default": "primary"
      },
      {
        "name": "size",
        "values": ["sm", "md", "lg"],
        "default": "md"
      }
    ]
  },

  "styles": {
    "display": "inline-flex",
    "borderRadius": "6px",
    "fontWeight": "500"
  },

  "variantStyles": {
    "intent": {
      "primary": { "backgroundColor": "#007bff", "color": "#fff" },
      "secondary": { "backgroundColor": "#6c757d", "color": "#fff" },
      "destructive": { "backgroundColor": "#dc3545", "color": "#fff" }
    },
    "size": {
      "sm": { "padding": "4px 8px", "fontSize": "12px" },
      "md": { "padding": "8px 16px", "fontSize": "14px" },
      "lg": { "padding": "12px 24px", "fontSize": "16px" }
    }
  }
}

How Variant Resolution Works

When a component is rendered with specific variant values, styles are merged in order:

  1. Base styles - Always applied
  2. Variant styles - Applied based on active variant values
  3. Compound styles - Applied when multiple variant conditions match
  4. Conditional styles - Applied based on prop conditions
import { resolveNodeStyles } from '@reallygoodwork/coral-core'

const styles = resolveNodeStyles(buttonNode, { intent: 'primary', size: 'lg' })

// Result:
// {
//   display: 'inline-flex',      // from base styles
//   borderRadius: '6px',         // from base styles
//   fontWeight: '500',           // from base styles
//   backgroundColor: '#007bff',  // from intent: primary
//   color: '#fff',               // from intent: primary
//   padding: '12px 24px',        // from size: lg
//   fontSize: '16px'             // from size: lg
// }

Compound Variants

Sometimes specific combinations of variants need special handling. Compound variants let you define styles that apply only when multiple conditions are met:

{
  "componentVariants": {
    "axes": [
      { "name": "intent", "values": ["primary", "destructive"], "default": "primary" },
      { "name": "size", "values": ["sm", "md", "lg"], "default": "md" }
    ],
    "compounds": [
      {
        "conditions": { "intent": "destructive", "size": "sm" },
        "description": "Small destructive buttons need extra visual emphasis"
      }
    ]
  },

  "compoundVariantStyles": [
    {
      "conditions": { "intent": "destructive", "size": "sm" },
      "styles": {
        "fontWeight": "bold",
        "border": "1px solid darkred"
      }
    }
  ]
}

State Styles

State styles handle interactive states like hover, focus, and disabled. They can be simple or variant-aware:

Simple State Styles

{
  "stateStyles": {
    "hover": { "opacity": "0.9" },
    "focus": { "outline": "2px solid blue", "outlineOffset": "2px" },
    "active": { "transform": "scale(0.98)" },
    "disabled": { "opacity": "0.5", "cursor": "not-allowed" }
  }
}

Variant-Aware State Styles

Different variants can have different hover effects:

{
  "stateStyles": {
    "hover": {
      "intent": {
        "primary": { "backgroundColor": "#0056b3" },
        "secondary": { "backgroundColor": "#5a6268" },
        "destructive": { "backgroundColor": "#c82333" }
      }
    },
    "focus": {
      "intent": {
        "primary": { "outline": "2px solid #007bff" },
        "secondary": { "outline": "2px solid #6c757d" },
        "destructive": { "outline": "2px solid #dc3545" }
      }
    }
  }
}

Per-Node Variant Responses

In complex components, different child nodes can respond to variants differently. Each node can define its own variantStyles:

{
  "name": "Card",
  "elementType": "div",

  "componentVariants": {
    "axes": [
      { "name": "variant", "values": ["elevated", "outlined", "filled"], "default": "elevated" }
    ]
  },

  "variantStyles": {
    "variant": {
      "elevated": { "boxShadow": "0 2px 8px rgba(0,0,0,0.1)" },
      "outlined": { "border": "1px solid #e0e0e0" },
      "filled": { "backgroundColor": "#f5f5f5" }
    }
  },

  "children": [
    {
      "name": "Header",
      "elementType": "div",
      "styles": { "padding": "16px" },
      "variantStyles": {
        "variant": {
          "elevated": { "borderBottom": "none" },
          "outlined": { "borderBottom": "1px solid #e0e0e0" },
          "filled": { "borderBottom": "1px solid #e8e8e8" }
        }
      }
    },
    {
      "name": "Content",
      "elementType": "div",
      "styles": { "padding": "16px" }
    }
  ]
}

Utility Functions

Get All Variant Combinations

import { getVariantCombinations } from '@reallygoodwork/coral-core'

const axes = [
  { name: 'intent', values: ['primary', 'secondary'] },
  { name: 'size', values: ['sm', 'md', 'lg'] }
]

const combinations = getVariantCombinations(axes)
// [
//   { intent: 'primary', size: 'sm' },
//   { intent: 'primary', size: 'md' },
//   { intent: 'primary', size: 'lg' },
//   { intent: 'secondary', size: 'sm' },
//   { intent: 'secondary', size: 'md' },
//   { intent: 'secondary', size: 'lg' }
// ]

Get Default Values

import { getDefaultVariantValues } from '@reallygoodwork/coral-core'

const axes = [
  { name: 'intent', values: ['primary', 'secondary'], default: 'primary' },
  { name: 'size', values: ['sm', 'md', 'lg'], default: 'md' }
]

const defaults = getDefaultVariantValues(axes)
// { intent: 'primary', size: 'md' }

Validate Variant Values

import { validateVariantValues } from '@reallygoodwork/coral-core'

const errors = validateVariantValues(
  { intent: 'invalid', size: 'md' },
  axes
)
// ['Invalid value "invalid" for axis "intent". Expected one of: primary, secondary']

Generate CSS Class Names

import { variantsToClassName } from '@reallygoodwork/coral-core'

const className = variantsToClassName({ intent: 'primary', size: 'lg' }, 'btn')
// 'btn-intent-primary btn-size-lg'

Resolving Styles

Single Node

import { resolveNodeStyles, getAllNodeStyles } from '@reallygoodwork/coral-core'

// Get merged styles for current variants
const styles = resolveNodeStyles(node, { intent: 'primary', size: 'lg' })

// Get all styles including hover, focus, etc.
const allStyles = getAllNodeStyles(node, { intent: 'primary', size: 'lg' })
// {
//   base: { ... },
//   hover: { ... },
//   focus: { ... },
//   active: { ... },
//   disabled: { ... }
// }

Full Tree

import { resolveTreeStyles } from '@reallygoodwork/coral-core'

// Resolve styles for entire component tree
const styleMap = resolveTreeStyles(rootNode, { intent: 'primary', size: 'lg' })

// Map<nodeId, resolvedStyles>
for (const [nodeId, styles] of styleMap) {
  console.log(`${nodeId}:`, styles)
}

Best Practices

1. Use Meaningful Axis Names

// Good
{ "name": "intent", "values": ["primary", "secondary", "destructive"] }
{ "name": "size", "values": ["sm", "md", "lg"] }

// Avoid
{ "name": "type", "values": ["1", "2", "3"] }
{ "name": "s", "values": ["a", "b", "c"] }

2. Always Set Defaults

Every axis should have a sensible default value:

{
  "name": "intent",
  "values": ["primary", "secondary", "destructive"],
  "default": "primary"  // Most common use case
}

3. Add Descriptions

Help consumers understand the purpose of each variant:

{
  "name": "intent",
  "values": ["primary", "secondary", "destructive"],
  "default": "primary",
  "description": "Visual style indicating the button's purpose"
}

4. Use Compounds Sparingly

Compound variants add complexity. Use them only for genuine edge cases:

// Good: Specific edge case
{
  "conditions": { "intent": "destructive", "size": "sm" },
  "styles": { "fontWeight": "bold" }
}

// Avoid: Could be handled differently
{
  "conditions": { "intent": "primary", "size": "md" },
  "styles": { ... }  // This is just the default case
}

5. Keep Variant Count Manageable

Too many variants make components hard to maintain:

// Good: 4 intents × 3 sizes = 12 combinations
"axes": [
  { "name": "intent", "values": ["primary", "secondary", "destructive", "ghost"] },
  { "name": "size", "values": ["sm", "md", "lg"] }
]

// Avoid: 5 × 5 × 3 × 3 = 225 combinations!
"axes": [
  { "name": "intent", "values": [...5] },
  { "name": "size", "values": [...5] },
  { "name": "shape", "values": [...3] },
  { "name": "elevation", "values": [...3] }
]

Generating React Components with Variants

When generating React components from Coral specifications, you can choose how variants are handled:

Automatic CVA Generation

When using coralToReact with styleFormat: 'className' and a component that has variants, CVA (Class Variance Authority) is automatically used:

import { coralToReact } from '@reallygoodwork/coral-to-react'

const { reactCode } = await coralToReact(buttonSpec, {
  styleFormat: 'className',
  variantStrategy: 'cva', // or omit - auto-detected when variants exist
  includeTypes: true
})

The generator automatically:

  • Converts Coral styles to Tailwind classes using @reallygoodwork/style-to-tailwind
  • Generates CVA configuration from variant definitions
  • Creates TypeScript props that include variant axes
  • Generates the cn helper function for class merging

Manual CVA Generation

You can also generate CVA configuration directly:

import { generateCVA, generateCNHelper } from '@reallygoodwork/coral-to-react'

// Generate CVA config and code
const cvaResult = generateCVA(buttonSpec)
// Returns: { config, code, imports, stateConfigs }

// Generate the cn helper utility
const cnHelper = generateCNHelper()
// Returns: cn function implementation using clsx and tailwind-merge

Variant Props in Generated Components

When variants are present, they're automatically added to the component props interface:

// Generated props interface includes variants
interface ButtonProps {
  label: string
  intent?: 'primary' | 'secondary' | 'destructive'
  size?: 'sm' | 'md' | 'lg'
  onClick?: () => void
}

The component uses these props to determine variant values:

export const Button = (props: ButtonProps) => {
  const className = buttonVariants({
    intent: props.intent,
    size: props.size
  })
  return <button className={className}>{props.label}</button>
}

Compound Variants with CVA

Compound variants are automatically converted to CVA compound variants:

{
  "compoundVariantStyles": [
    {
      "conditions": { "intent": "destructive", "size": "sm" },
      "styles": { "fontWeight": "bold" }
    }
  ]
}

Generates:

const buttonVariants = cva('...', {
  variants: { ... },
  compoundVariants: [
    {
      intent: 'destructive',
      size: 'sm',
      class: 'font-bold'
    }
  ]
})

Learn more: For complete details on generating React components with variants, see the Coral to React documentation.

Next Steps

API Documentation

On this page