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
colorPalettepropView,Container,Text,Headingcomponentsstyled(),css(),sva()- Lucide icons
v2
- Tailwind CSS v4 (utility-first)
- Base UI primitives
intent+emphasisprops- 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/postcss3. 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 component | v2 replacement | Reference |
|---|---|---|
View | <div> with Tailwind layout classes | Foundation |
Container | <div className="container-8xl"> | Foundation |
Text | <p>, <span> with utility classes | Foundation |
Heading | <h1>-<h6> with text-display-* utilities | Foundation |
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 colorPalette | v2 intent |
|---|---|
primary | brand |
neutral | neutral |
accent | accent |
danger | danger |
success | success |
warning | warning |
information | info |
{/* 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 token | Tailwind value | Pixels |
|---|---|---|
| '0' | 0 | 0px |
| '25' | 0.5 | 2px |
| '50' | 1 | 4px |
| '75' | 1.5 | 6px |
| '100' | 2 | 8px |
| '125' | 2.5 | 10px |
| '150' | 3 | 12px |
| '200' | 4 | 16px |
| '250' | 5 | 20px |
| '300' | 6 | 24px |
| '350' | 7 | 28px |
| '400' | 8 | 32px |
| '500' | 10 | 40px |
| '600' | 12 | 48px |
| '700' | 14 | 56px |
| '800' | 16 | 64px |
| '900' | 18 | 72px |
| '1000' | 20 | 80px |
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
Iconsuffix:HeartIconnotHeart - Set size with
className, not thesizeprop:className='size-4' - Always set
weight='bold' - Use
@phosphor-icons/react/ssrin 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.
| Purpose | Utility | Use for |
|---|---|---|
| Background | bg-normal | Page background |
bg-subtle | Tinted surface | |
bg-raised | Elevated card | |
bg-sunken | Recessed area | |
| Text | text-normal | Body text |
text-subtle | Secondary text | |
text-strong | Headings | |
| Border | border-subtle | Dividers |
border-normal | Standard 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.
| Component | Description |
|---|---|
Prose | Rich content container for CMS/markdown |
Badge | Status indicators with intent and emphasis |
Card | Content container with elevation |
Input | Text input with field-interactive states |
Textarea | Multi-line text input |
Field | Form field with label, helper, and error |
Select | Dropdown with full sub-component API |
Combobox | Searchable select with filtering |
RadioGroup | Radio buttons with card appearance |
Fieldset | Form group with legend |
Accordion | Collapsible sections |
Breadcrumb | Navigation breadcrumbs |
Separator | Visual divider |
LinkButton | Link styled as a button (replaces Button as/asChild) |
LinkIconButton | Icon-only link styled as a button |
Quick reference
| v1 pattern | v2 replacement |
|---|---|
| import { css } from 'styled-system/css' | Tailwind utility classes |
| import { styled } from 'styled-system/jsx' | cn() from '@oztix/roadie-core/utils' |
| sva() / cva() recipes | CVA (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 script | getThemeScript() from '@oztix/roadie-core/theme' |
| gap='200' | className="gap-4" |
| {{ base: "x", md: "y" }} | "x md:y" |
| Lucide icons | Phosphor icons (bold, Icon suffix) |
| @ark-ui/react | @base-ui/react (used internally by components) |
Next steps
- Browse the component docs for usage examples and live previews
- Read the token system overview to understand the intent/emphasis architecture
- Review the foundation pages for detailed styling guidance