Skip to content

Mastering Astryx / lesson 4 of 13

Styling Components & StyleX Integration

Configure the StyleX compiler, write the board's centralized stylesheet, and apply custom styles to Astryx components via the xstyle prop.

Astryx relies on Meta’s StyleX (@stylexjs/stylex) for styling. StyleX compiles styles down to atomic, collision-free CSS classes at build time. In this lesson we configure the compiler once, write the board’s centralized stylesheet (src/app/styles.ts), and apply it to the shell and placeholder columns via the xstyle prop.

Files this lesson: babel.config.js (new), postcss.config.js (new), src/app/globals.css (extended), src/app/styles.ts (new), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.

Prerequisite: Configure the StyleX Compiler (Next.js 16 + Turbopack)

Astryx ships pre-compiled, so consuming its components needs no StyleX compiler setup (see lesson 1). But stylex.create is compile-time-only: when a call reaches the runtime un-compiled, @stylexjs/stylex throws:

Unexpected 'stylex.create' call at runtime. Styles must be compiled by '@stylexjs/babel-plugin'.

Configure the compiler once per project before writing the styles below. The same setup covers the custom StyleX you’ll write in lessons 8 and 12, so you only pay this cost once.

1. Install the Compiler Plugins

Terminal window
npm install -D @stylexjs/babel-plugin @stylexjs/postcss-plugin autoprefixer

Keep @stylexjs/babel-plugin and @stylexjs/postcss-plugin in sync with the @stylexjs/stylex version installed in lesson 1 (check with npm list @stylexjs/stylex).

2. Babel Configuration

Create babel.config.js in the project root. The StyleX Babel plugin transforms every stylex.create call into compiled, atomic CSS references at build time:

babel.config.js
const path = require('path');
const dev = process.env.NODE_ENV !== 'production';
module.exports = {
presets: ['next/babel'],
plugins: [
[
'@stylexjs/babel-plugin',
{
dev,
runtimeInjection: false,
enableInlinedConditionalMerge: true,
treeshakeCompensation: true,
aliases: { '@/*': [path.join(__dirname, 'src/*')] },
unstable_moduleResolution: { type: 'commonJS' },
},
],
],
};

The aliases mapping assumes the --src-dir layout from lesson 1 (@/*./src/*). The board’s own imports are all relative, so this mainly future-proofs alias usage.

3. PostCSS Configuration

Create postcss.config.js. The PostCSS plugin generates the compiled CSS rules and reuses the same Babel plugin config so both halves of the pipeline agree on the transformation:

postcss.config.js
const babelConfig = require('./babel.config');
module.exports = {
plugins: {
'@stylexjs/postcss-plugin': {
include: [
'src/**/*.{js,jsx,ts,tsx}',
'app/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
],
babelConfig: {
babelrc: false,
parserOpts: { plugins: ['typescript', 'jsx'] },
plugins: babelConfig.plugins,
},
useCSSLayers: true,
},
autoprefixer: {},
},
};

The include globs tell the plugin which files may contain StyleX styles — make sure they cover src/ (and therefore src/app/components/ and src/app/hooks/) when using the src directory layout.

4. Add the @stylex Directive

The PostCSS plugin injects the compiled atomic CSS where the @stylex directive appears. Append it to src/app/globals.css, after the Astryx imports:

src/app/globals.css
@import '@astryxdesign/core/reset.css';
@import '@astryxdesign/core/astryx.css';
@import '@astryxdesign/theme-neutral/theme.css';
@stylex;

5. Restart the Dev Server

Clear the Next.js cache and restart. Assuming you’re on the Next.js 16 template that create-next-app@latest installs today, no next.config changes are needed — since Next.js 16.0.3 this setup runs under Turbopack, the default bundler:

Terminal window
rm -rf .next
npm run dev

Turbopack giving you trouble? next dev --webpack opts out on Next.js 16, but the setup above is the tested Turbopack path.

The Board’s Centralized Stylesheet: styles.ts

Create src/app/styles.ts. This file holds every custom StyleX rule the board needs — layout, elevation, drag feedback. One stylesheet, imported by every component:

src/app/styles.ts
import * as stylex from '@stylexjs/stylex';
// StyleX inlines every value inside stylex.create at build time, so the
// compiler needs the width defined here rather than imported from './data'
// (see the note below).
const COLUMN_WIDTH = 300;
export const styles = stylex.create({
boardColumns: {
overflowX: 'auto',
overflowY: 'hidden',
height: '100%',
padding: 'var(--spacing-4)',
},
columnShell: {
flexShrink: 0,
flexBasis: COLUMN_WIDTH,
height: '100%',
},
card: {
cursor: 'grab',
userSelect: 'none',
touchAction: 'none',
transition: 'box-shadow 120ms ease',
':hover': {
boxShadow: 'var(--shadow-med)',
},
// Black shadows vanish on dark surfaces, so strengthen the hover state
// with a subtle light ring and a deeper drop shadow in dark mode.
'@media (prefers-color-scheme: dark)': {
':hover': {
boxShadow:
'0 0 0 1px rgba(255, 255, 255, 0.14), 0 8px 24px rgba(0, 0, 0, 0.55)',
},
},
},
floating: {
position: 'fixed',
insetBlockStart: 0,
insetInlineStart: 0,
pointerEvents: 'none',
cursor: 'grabbing',
boxShadow: 'var(--shadow-high)',
zIndex: 1000,
'@media (prefers-color-scheme: dark)': {
boxShadow:
'0 0 0 1px rgba(255, 255, 255, 0.16), 0 12px 32px rgba(0, 0, 0, 0.6)',
},
},
floatingAt: (x: number, y: number, width: number) => ({
width,
transform: `translate(${x}px, ${y}px)`,
}),
ghost: (height: number) => ({
height,
borderRadius: 'var(--radius-container)',
backgroundColor: 'var(--color-background-muted)',
'@media (prefers-color-scheme: dark)': {
outline: '1px dashed rgba(148, 163, 184, 0.5)',
},
}),
toolbarDivider: {
height: 'auto',
marginBlock: 'var(--spacing-1)',
alignSelf: 'stretch',
},
columnEmptyState: {
paddingBlock: 'var(--spacing-10)',
},
});

What’s in here

Why the local COLUMN_WIDTH instead of import { COLUMN_WIDTH } from './data'? StyleX evaluates every value inside stylex.create at build time. The PostCSS plugin processes styles.ts on its own, and its CommonJS resolver can’t load ./data — a .ts module that also imports icons and types — so the import fails with “Could not resolve the path to the imported file.” Values used inside stylex.create must be defined in the same file — or in a .stylex.ts file, StyleX’s dedicated constant/theme module format, which the compiler can resolve. When data.ts arrives in lesson 6 it keeps its own COLUMN_WIDTH export for reference; it just can’t feed a StyleX file.

The xstyle Prop

Every core Astryx component accepts an xstyle prop. This applies custom StyleX rules directly onto Astryx elements without breaking their design-system encapsulations. You can pass a single style object, or an array of them:

<Card xstyle={[styles.card, styles.floatingAt(x, y, width)]}>
{children}
</Card>

Applying the Styles: KanbanBoard.tsx

Rewrite src/app/components/KanbanBoard.tsx to use the stylesheet — the column strip gets boardColumns, each column gets columnShell, and each placeholder task card gets card (so it lifts on hover):

src/app/components/KanbanBoard.tsx
'use client';
import { Section } from '@astryxdesign/core/Section';
import {
Layout,
LayoutHeader,
LayoutContent,
HStack,
VStack,
} from '@astryxdesign/core/Layout';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Card } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { useTranslator } from '@astryxdesign/core/i18n';
import { usePreferences } from '../providers';
import { styles } from '../styles';
export default function KanbanBoard() {
const t = useTranslator();
const { locale, setLocale } = usePreferences();
return (
<Section height="100dvh">
<Layout
height="fill"
header={
<LayoutHeader hasDivider padding={4}>
<HStack hAlign="between" vAlign="center">
<Heading level={3}>{t('@app.board.title')}</Heading>
<HStack gap={2} vAlign="center">
{/* Tabular numbers keep digits from jittering as counts change */}
<Text type="supporting" color="secondary" hasTabularNumbers>
4 {t('@app.tasks')}
</Text>
<Button
label={t('@app.switchLanguage')}
variant="secondary"
onClick={() => setLocale(locale === 'en' ? 'fr' : 'en')}
/>
</HStack>
</HStack>
</LayoutHeader>
}
content={
<LayoutContent padding={4}>
<LayoutContent padding={0}>
<HStack gap={4}>
<HStack gap={4} xstyle={styles.boardColumns}>
{[0, 1, 2, 3].map(i => (
<Card
key={i}
variant="muted"
padding={3}
width={300}
style={{ flexShrink: 0 }}
padding={0}
xstyle={styles.columnShell}
>
<Heading level={4}>Column {i + 1}</Heading>
<Text type="supporting" color="secondary">
Placeholder — data arrives in lesson 6.
</Text>
<VStack gap={3} padding={3}>
<Heading level={4}>Column {i + 1}</Heading>
<Card padding={3} xstyle={styles.card}>
<Heading level={5}>Placeholder task</Heading>
<Text type="supporting" color="secondary" maxLines={2}>
A placeholder card inside the styled column shell.
</Text>
</Card>
</VStack>
</Card>
))}
</HStack>
</LayoutContent>
}
/>
</Section>
);
}

Note LayoutContent padding={0} — the column strip owns its own padding via styles.boardColumns, so the content body adds none.

See It in Action

Run npm run dev (after clearing .next once). Hover any placeholder task card — it lifts with the --shadow-med elevation. In dark mode (mode="system" follows your OS), the hover picks up the light ring instead. The columns keep their fixed 300px width and the strip scrolls horizontally if the window is too narrow.

The floating and ghost rules are compiled but not yet visible — they come alive with drag-and-drop in lessons 11–12.

What You Built

In the next lesson, we will break down the theme system and design tokens — and add a light/dark toggle to the board.


Share this post on:

Previous
Internationalization & Localization (i18n)
Next
Theme System & Design Tokens