Migrating to v2

Roadie v2 replaces PandaCSS with Tailwind CSS v4 and Ark UI with Base UI. This guide covers every change you need to make.

What changed

v1

  • PandaCSS (CSS-in-JS)
  • Ark UI primitives
  • colorPalette prop
  • View, Container, Text, Heading components
  • styled(), css(), sva()
  • Lucide icons

v2

  • Tailwind CSS v4 (utility-first)
  • Base UI primitives
  • intent + emphasis props
  • Raw HTML elements + utility classes
  • CVA + cn() utility
  • Phosphor icons (bold weight)

Setup

1. Remove PandaCSS

pnpm remove @pandacss/dev
rm panda.config.ts
rm -rf styled-system/

Delete all generated PandaCSS artifacts. Remove any prepare or codegen scripts that ran panda codegen.

2. Install Tailwind v4

pnpm add -D tailwindcss@4 @tailwindcss/postcss

3. Update your CSS entry

/* app/globals.css */
@import '@oztix/roadie-core/css';

/* Scan component dist for Tailwind class strings */
@source "../../node_modules/@oztix/roadie-components/dist";

The @source directive tells Tailwind to scan the component library so all component classes are included in your output CSS.

4. Update PostCSS config

// postcss.config.js
module.exports = {
  plugins: {
    '@tailwindcss/postcss': {}
  }
}

Removed components

These components have been removed. Replace them with raw HTML elements and Tailwind utility classes.

v1 componentv2 replacementReference
View<div> with Tailwind layout classesFoundation
Container<div className="container-8xl">Foundation
Text<p>, <span> with utility classesFoundation
Heading<h1>-<h6> with text-display-* utilitiesFoundation

View migration

View was a flex-column layout primitive. Replace with div and Tailwind classes. v2 is grid-first — use grid for vertical stacks.

Default vertical stack

{/* v1 */}
<View gap='200'>
  <p>Item 1</p>
  <p>Item 2</p>
</View>
{/* v2 */}
<div className='grid gap-4'>
  <p>Item 1</p>
  <p>Item 2</p>
</div>

Horizontal row

{/* v1 */}
<View
  flexDirection='row'
  gap='100'
  alignItems='center'
>
  <Icon />
  <p>Label</p>
</View>
{/* v2 */}
<div className='flex items-center gap-2'>
  <Icon />
  <p>Label</p>
</div>

Grid layout

{/* v1 */}
<View
  display='grid'
  gridTemplateColumns='repeat(3, 1fr)'
  gap='200'
>
  {items}
</View>
{/* v2 */}
<div className='grid grid-cols-3 gap-4'>
  {items}
</div>

Container migration

{/* v1 */}
<Container>
  {content}
</Container>
{/* v2 */}
<div className='container-8xl'>
  {content}
</div>

Text migration

The Text component is removed. Use raw <p> or <span> with utility classes.

{/* v1 */}
<Text
  emphasis='subtle'
  size='sm'
>
  Secondary text
</Text>
{/* v2 */}
<p className='text-sm text-subtle'>
  Secondary text
</p>

Heading migration

The Heading component is removed. Use raw heading elements with display text utilities.

{/* v1 */}
<Heading
  as='h1'
  textStyle='display.ui'
  level={1}
>
  Page title
</Heading>
{/* v2 */}
<h1 className='text-display-ui-1 text-strong'>
  Page title
</h1>

Two display style families are available: text-display-ui-1 through 6 for UI headings, and text-display-prose-1 through 6 for long-form content. See the Typography foundation.

Prop and API changes

colorPalette is now intent

The colorPalette prop is renamed to intent. Some values are also renamed:

v1 colorPalettev2 intent
primarybrand
neutralneutral
accentaccent
dangerdanger
successsuccess
warningwarning
informationinfo
{/* v1 */}
<Button colorPalette='primary'>
  Save
</Button>
{/* v2 */}
<Button intent='brand' emphasis='strong'>
  Save
</Button>

Button as link → LinkButton

In v1, you could use asChild or the as prop on Button to render as a link, or apply button recipe styles to an anchor. In v2, use the dedicated LinkButton and LinkIconButton components instead. They accept an as prop for custom link components like Next.js Link.

{/* v1 — asChild or as prop */}
<Button asChild colorPalette='primary'>
  <a href='/about'>About</a>
</Button>

{/* v1 — recipe on anchor */}
<a href='/about' className={button()}>
  About
</a>
{/* v2 — LinkButton */}
<LinkButton href='/about' intent='brand'
  emphasis='strong'>
  About
</LinkButton>

{/* v2 — with Next.js Link */}
<LinkButton as={Link} href='/about'>
  About
</LinkButton>

appearance is now emphasis

The appearance prop is renamed to emphasis. This applies to Accordion and any custom components that used the old name.

{/* v1 */}
<Accordion appearance='contained'>
  ...
</Accordion>
{/* v2 */}
<Accordion emphasis='subtle'>
  ...
</Accordion>

Dark mode

Dark mode is now class-based instead of data-attribute-based. The CSS also sets color-scheme so native browser UI (scrollbars, form controls) matches the active theme.

{/* v1 */}
<div data-color-mode='dark'>
  ...
</div>
{/* v2 */}
<div className='dark'>
  ...
</div>

Use getThemeScript() from @oztix/roadie-core/theme for flash-free SSR setup. For React apps, ThemeProvider now manages dark mode — use useTheme() instead of useAccent().

// Flash prevention (any framework — no React dependency)
import { getThemeScript } from '@oztix/roadie-core/theme'
// <script>{getThemeScript({ followSystem: true })}</script>

// React: useAccent() is deprecated, use useTheme()
import { useTheme } from '@oztix/roadie-components'
const { isDark, setDark, accentColor, setAccentColor } = useTheme()

See the Colors foundation for full dark mode setup instructions.

The intent and emphasis system

v2 introduces a two-axis styling system. This is the most important concept to understand.

Intent = which color palette

Sets CSS custom properties (--intent-*). No visual presentation on its own. Children inherit via CSS cascade.

<div className='intent-accent'>
  {/* Everything inside uses the accent palette */}
  <Button emphasis='strong'>Accent button</Button>
  <Badge>Accent badge</Badge>
</div>

Available: neutral, brand, accent, danger, success, warning, info

Emphasis = how much visual weight

Combined shortcuts that set background, text, border, and interaction states together.

<Button emphasis='strong'>Primary action</Button>
<Button emphasis='normal'>Secondary</Button>
<Button emphasis='subtle'>Tertiary</Button>
<Card emphasis='raised'>Elevated card</Card>

Available: strong, normal, subtle, subtler, raised, sunken, floating, inverted, overlay

Components inherit their intent from the CSS cascade — you don't need to pass intent to every component. Wrap a section in intent-accent and all children pick it up automatically. See the Tokens overview for the full architecture.

Spacing tokens

PandaCSS used string-based spacing tokens. Tailwind uses a numeric scale where 1 = 4px.

PandaCSS tokenTailwind valuePixels
'0'00px
'25'0.52px
'50'14px
'75'1.56px
'100'28px
'125'2.510px
'150'312px
'200'416px
'250'520px
'300'624px
'350'728px
'400'832px
'500'1040px
'600'1248px
'700'1456px
'800'1664px
'900'1872px
'1000'2080px

Apply spacing with Tailwind utilities: gap-4, p-6, m-2, etc.

Responsive syntax

PandaCSS used object-based responsive values. Tailwind uses breakpoint prefixes.

{/* v1 — PandaCSS responsive object */}
<View
  padding={{ base: '200', md: '400' }}
  flexDirection={{
    base: 'column',
    md: 'row'
  }}
>
  {content}
</View>
{/* v2 — Tailwind breakpoint prefixes */}
<div className='grid gap-4 p-4 md:flex md:flex-row md:p-8'>
  {content}
</div>

Tailwind breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px), 2xl (1536px). Mobile-first — unprefixed classes apply at all sizes.

Removing CSS-in-JS patterns

All PandaCSS runtime APIs are replaced with Tailwind utility classes and CVA.

css() and inline styles

{/* v1 */}
import { css } from 'styled-system/css'

<div className={css({
  display: 'flex',
  gap: '200',
  padding: '400',
  bg: 'neutral.2'
})}>
  {content}
</div>
{/* v2 */}
<div className='flex gap-4 p-8 bg-subtle'>
  {content}
</div>

sva() and cva() recipes

Replace PandaCSS slot recipes with class-variance-authority (CVA) and Tailwind classes.

// v1 — PandaCSS recipe
import { sva } from 'styled-system/css'

const card = sva({
  slots: ['root', 'header', 'body'],
  base: {
    root: { bg: 'neutral.1', p: '400' },
    header: { fontWeight: 'bold' },
    body: { color: 'neutral.11' }
  }
})
// v2 — CVA + Tailwind
import { cva } from 'class-variance-authority'
import { cn } from '@oztix/roadie-core/utils'

const cardVariants = cva(
  'rounded-xl emphasis-raised p-8', {
  variants: {
    emphasis: {
      raised: 'emphasis-raised',
      subtle: 'emphasis-subtle',
    }
  }
})

styled() factory

// v1 — PandaCSS styled factory
import { styled } from 'styled-system/jsx'

const StyledCard = styled('div', {
  base: { bg: 'neutral.1', p: '400' }
})
// v2 — Plain component + cn()
import { cn } from '@oztix/roadie-core/utils'

function Card({ className, ...props }) {
  return (
    <div
      className={cn(
        'rounded-xl emphasis-raised p-8',
        className
      )}
      {...props}
    />
  )
}

splitCssProps()

splitCssProps() is removed. Since v2 uses className strings instead of CSS-in-JS props, there are no CSS props to split. Simply spread all props to the element.

Import cleanup

Remove all PandaCSS imports:

// Remove all of these:
import { css } from 'styled-system/css'
import { styled } from 'styled-system/jsx'
import { sva, cva } from 'styled-system/css'
import { token } from 'styled-system/tokens'
import { View, Container } from 'styled-system/jsx'
import type { HTMLStyledProps } from 'styled-system/jsx'
import type { JsxStyleProps } from 'styled-system/types'

Icon migration

v2 uses Phosphor Icons instead of Lucide. Always use bold weight.

// v1 — Lucide
import { Heart } from 'lucide-react'

<Heart size={16} />
// v2 — Phosphor (client component)
import { HeartIcon } from '@phosphor-icons/react'

<HeartIcon weight='bold' className='size-4' />

Key differences

  • Use the Icon suffix: HeartIcon not Heart
  • Set size with className, not the size prop: className='size-4'
  • Always set weight='bold'
  • Use @phosphor-icons/react/ssr in server components
  • Sizing tiers: size-3 (XS), size-4 (SM/default), size-5 (MD), size-6 (LG)

See the Iconography foundation for complete guidelines.

Color utilities

Default Tailwind color utilities (bg-red-500, text-blue-300) are disabled. Use semantic color utilities instead.

PurposeUtilityUse for
Backgroundbg-normalPage background
bg-subtleTinted surface
bg-raisedElevated card
bg-sunkenRecessed area
Texttext-normalBody text
text-subtleSecondary text
text-strongHeadings
Borderborder-subtleDividers
border-normalStandard borders

These utilities respond to the current intent context. Inside an intent-danger wrapper, bg-subtle uses the danger scale. See the Colors foundation.

New in v2

These features have no v1 equivalent. Adopt them as needed.

Interaction utilities

Two CSS utilities handle interactive element styling:

  • is-interactive — for buttons, cards, clickable elements. Provides cursor, transitions, active scale, focus ring, disabled state.
  • is-interactive-field — for form inputs. Provides state-based color transitions: neutral at rest, accent on focus, danger when invalid.
  • is-interactive-field-group — for composite form controls like the Combobox input group.

See Interactions foundation.

Motion tokens

Duration and easing tokens replace hardcoded animation values. All motion respects prefers-reduced-motion automatically.

/* Use tokens for custom transitions */
.my-element {
  transition:
    opacity var(--duration-moderate) var(--ease-standard),
    transform var(--duration-slow) var(--ease-enter);
}

/* Or use built-in motion utilities */
.entering {
  animation: motion-fade-in var(--duration-slow) var(--ease-enter);
}

See Motion foundation.

Shape tiers

Consistent border-radius tiers across all components:

  • rounded-sm — inline (marks, highlights)
  • rounded-md — small (code, prose images)
  • rounded-lg — field (inputs, textareas)
  • rounded-xl — container (cards, popovers)
  • rounded-2xl — large (modals, dialogs)
  • rounded-full — buttons, badges, pills

See Shape foundation.

New components

v2 adds 13 new components. Browse the component docs for usage examples.

ComponentDescription
ProseRich content container for CMS/markdown
BadgeStatus indicators with intent and emphasis
CardContent container with elevation
InputText input with field-interactive states
TextareaMulti-line text input
FieldForm field with label, helper, and error
SelectDropdown with full sub-component API
ComboboxSearchable select with filtering
RadioGroupRadio buttons with card appearance
FieldsetForm group with legend
AccordionCollapsible sections
BreadcrumbNavigation breadcrumbs
SeparatorVisual divider
LinkButtonLink styled as a button (replaces Button as/asChild)
LinkIconButtonIcon-only link styled as a button

Quick reference

v1 patternv2 replacement
import { css } from 'styled-system/css'Tailwind utility classes
import { styled } from 'styled-system/jsx'cn() from '@oztix/roadie-core/utils'
sva() / cva() recipesCVA (class-variance-authority)
splitCssProps()Removed — not needed
<View><div className="grid gap-*">
<Container><div className="container-8xl">
<Text><p>, <span> + utility classes
<Heading><h1>-<h6> + text-display-* utilities
colorPalette="primary"intent="brand"
colorPalette="information"intent="info"
appearance="contained"emphasis="subtle"
data-color-mode='dark'className='dark'
useAccent()useTheme() from @oztix/roadie-components
Hand-rolled theme scriptgetThemeScript() from '@oztix/roadie-core/theme'
gap='200'className="gap-4"
{{ base: "x", md: "y" }}"x md:y"
Lucide iconsPhosphor icons (bold, Icon suffix)
@ark-ui/react@base-ui/react (used internally by components)

Next steps