Styling Components
How to customize component appearance: xstyle prop, Tailwind, StyleX, class, rest props, compound component patterns, theming utils, and styling-library interop.Overview #
There are several ways to style things. Here is when to use each:
| Approach | Use for | Example |
|---|---|---|
| StyleX | Component-specific overrides, reusable styles, pseudo-classes, and typed tokens | const styles = stylex.create(...); <Button xstyle={styles.save} /> |
| Tailwind utilities | Layout, wrappers, and utility styling | class="flex gap-3 p-4" |
| class | Integrating with external CSS, a scoped <style> block, or Tailwind on components | class="my-card shadow-lg" |
| Styling-library token aliases | Keeping Panda, Chakra, Emotion, UnoCSS, CSS Modules, or Sass in sync with the system | colors.surface = 'var(--color-background-surface)' |
All approaches resolve to the same design tokens, so theming and dark mode work regardless of which you choose. For external styling libraries, run astryx-svelte docs styling-libraries; it covers Tailwind, StyleX, Panda, Chakra, CSS-in-JS, CSS Modules, Sass, and useTheme() for non-CSS processing.
xstyle Prop #
Every component accepts an xstyle prop for style customization. It accepts StyleX styles created via stylex.create(), not inline objects or class name strings. StyleX styles are compiled at build time for optimal deduplication and dead-code elimination.
StyleX may only be imported from a .ts module, never from a .svelte file: the bundler plugin Babel-parses any module that imports @stylexjs/stylex, and it would read Svelte markup as JSX. Author the styles in a sibling .stylex.ts and import the object into the component.
tsimport * as stylex from '@stylexjs/stylex';export const overrides = stylex.create({card: { maxWidth: 400, marginBlock: 16 },saveButton: { alignSelf: 'flex-end' }});
svelte<script lang="ts">import { Button, Card } from '@astryx-svelte/core';import { overrides } from './card.stylex.js';</script><Card xstyle={overrides.card} /><Button label="Save" xstyle={overrides.saveButton} />
tsimport * as stylex from '@stylexjs/stylex';export const overrides = stylex.create({card: {boxShadow: {default: 'none',':hover': { '@media (hover: hover)': '0 4px 12px rgba(0,0,0,0.1)' }}}});
- All xstyle values must come from stylex.create()
- Pseudo-classes (:hover, :focus-visible) are supported inside stylex.create
- All :hover styles MUST use @media (hover: hover) guard
- For non-StyleX styling (Tailwind, external CSS, a scoped <style> block), use class instead
Tailwind Integration #
Tailwind v4 reads its theme from CSS custom properties, and so does every Astryx component — so the two meet at the token layer with no plugin. Map Tailwind's theme variables to the system tokens once with @theme inline, and utility classes become token-backed: colors, spacing, radius, shadows, and typography all resolve to the active theme.
css@layer reset, theme, base, astryx-base, astryx-theme, product, utilities;@import 'tailwindcss/theme.css' layer(theme);@import 'tailwindcss/preflight.css' layer(base);@import '@astryx-svelte/core/base.css';@import '@astryx-svelte/theme-neutral/theme.css';@import 'tailwindcss/utilities.css' layer(utilities);@theme inline {--color-surface: var(--color-background-surface);--color-primary: var(--color-text-primary);--color-border: var(--color-border);--radius-lg: var(--radius-container);--spacing: var(--spacing-1);}
svelte<div class="text-primary bg-surface rounded-lg p-4 flex gap-3"><Button label="Save" variant="primary" /><Button label="Cancel" variant="secondary" /></div>
The bridge is pure CSS with zero JS. Theme changes (dark mode, custom themes) apply automatically because the utilities reference the same CSS custom properties that components use. For other styling libraries that follow the same aliasing pattern, run astryx-svelte docs styling-libraries.
class and style Props #
Every component also accepts standard class and style props. class is appended after the component's own classes. style is merged after StyleX inline styles, so consumer values win on conflict.
A caveat specific to Svelte: styles in a <style> block are scoped to the component that declares them, and a class you pass down does not carry that scope. Use :global(.my-card) in the parent, or pass the styling through xstyle instead.
svelte<Card class="shadow-lg hover:shadow-xl transition-shadow">...</Card><Button label="Save" class="my-app-save-btn" />
For layout and wrapper styling, Tailwind utilities on class work well. For component-specific overrides (padding, colors, borders), prefer xstyle; it integrates with StyleX deduplication and the component's internal style pipeline.
Rest Props (Prop Drilling) #
Components extend HTML attributes and spread rest props onto their root DOM element. This means data-* attributes, aria-* attributes, event handlers, and other HTML props pass through automatically.
svelte<Carddata-testid="user-card"data-user-id={user.id}onmouseenter={handleHover}aria-label="User profile card">...</Card>
Element access is an attachment, not a ref. Components that expose their root element take an attach… prop typed Attachment<HTMLElement>; spread it onto the component and it runs when the element mounts. There is no ref prop and no bind:this on an Astryx component.
svelte<script lang="ts">import { Card } from '@astryx-svelte/core';import type { Attachment } from 'svelte/attachments';const measure: Attachment<HTMLElement> = (node) => {console.log(node.getBoundingClientRect());};</script><Card {@attach measure}>...</Card>
A few HTML attributes are intentionally omitted from the base type (contenteditable, title). children is not in the base type either; components that accept children declare it explicitly as a Snippet, so slot-based components don't silently drop content.
Compound Components #
Complex components are composed from smaller components. Each sub-component accepts its own xstyle, class, and rest props. You style the parts individually; there's no single "drill into sub-part" prop.
Where upstream passes a sub-tree as a prop (header={<LayoutHeader/>}), the same slot is a snippet here: declare it inside the component with {#snippet header()}.
svelte<script lang="ts">import {Button,Dialog,Heading,Layout,LayoutContent,LayoutFooter,LayoutHeader,TextInput} from '@astryx-svelte/core';import { overrides } from './dialog.stylex.js';let name = $state('');</script><Dialog {isOpen} onClose={close} xstyle={overrides.dialog}><Layout>{#snippet header()}<LayoutHeader hasDivider><Heading level={2}>Edit Profile</Heading></LayoutHeader>{/snippet}{#snippet content()}<LayoutContent xstyle={overrides.content}><TextInput label="Name" bind:value={name} /></LayoutContent>{/snippet}{#snippet footer()}<LayoutFooter hasDivider><Button label="Cancel" variant="secondary" onclick={close} /><Button label="Save" variant="primary" onclick={save} /></LayoutFooter>{/snippet}</Layout></Dialog>
The pattern: the parent component (Dialog) controls structure and behavior, child components (Layout, Header, Button) control their own appearance. Style each piece where it lives.
Preferred Selector Surface: Data Attributes #
When external CSS needs to target an Astryx component by prop or state, combine the stable component class with reflected data attributes. The component class identifies the component (.astryx-button, .astryx-card); data attributes identify the axis and value (data-variant, data-size, data-level, etc.). This is the preferred selector surface for new CSS because it is explicit and collision-resistant.
css.my-app .astryx-button[data-variant="primary"] {/* primary buttons in this app context */}.my-app .astryx-button[data-variant="primary"][data-size="sm"] {/* small primary buttons */}.my-app .astryx-heading[data-level="2"] {/* level 2 headings; numeric values stay literal in data attrs */}
svelte<!-- <Button variant="primary" size="sm" />preferred selector attrs: data-variant="primary" data-size="sm" --><!-- <Card variant="elevated" />preferred selector attrs: data-variant="elevated" --><!-- <Heading level={2} />preferred selector attrs: data-level="2" -->
A selector written in a component's own <style> block is scoped and will not match these classes unless you wrap it in :global(...). Put app-wide component CSS in a global stylesheet.
For systematic theming, use defineTheme component overrides instead of raw CSS selectors. defineTheme keeps the higher-level prop:value API (variant:primary, size:sm) and handles selector generation for you. Run astryx-svelte docs theme for the full theming guide.
Deprecated: Bare Prop and State Classes #
Astryx still emits legacy bare prop/state classes such as .primary, .sm, .level-2, and .checked for compatibility with existing apps and built themes. Do not write new CSS against these bare classes. The stable base component classes (.astryx-button, .astryx-card, etc.) are not deprecated; only the unprefixed prop/state classes are the legacy surface.
css/* Deprecated compatibility selector — avoid in new CSS */.my-app .astryx-button.primary {/* use .astryx-button[data-variant="primary"] instead */}/* Deprecated compatibility selector — avoid in new CSS */.my-app .astryx-heading.level-2 {/* use .astryx-heading[data-level="2"] instead */}
Design Tokens #
When writing custom styles, use design tokens instead of hardcoded values. Tokens are CSS custom properties that adapt to the active theme and color mode. The system provides tokens for spacing, color, radius, shadow, typography, and size.
tsimport * as stylex from '@stylexjs/stylex';export const styles = stylex.create({surface: {padding: 'var(--spacing-4)',borderRadius: 'var(--radius-container)',backgroundColor: 'var(--color-background-surface)'}});
The token *names* are the public surface; the defineVars objects that mint them are internal to @astryx-svelte/core and are not published from any subpath, so a var(--token) string is how you reference one. Outside StyleX, tokenVar() and tokenVars from @astryx-svelte/core/theme return the same references as values for a styling-library config.
See astryx-svelte docs tokens for the full token reference (all spacing, color, radius, shadow, and typography tokens with values). See astryx-svelte docs theme for how to override tokens via defineTheme.
StyleX Build Setup (required for swizzled components) #
Astryx components ship pre-compiled, so consuming the published package needs no StyleX setup. But astryx-svelte swizzle <Component> copies the raw StyleX *source* into your app, and StyleX source requires a build-time StyleX compiler to produce atomic CSS. Without one the component compiles but renders completely unstyled: no error, no warning. If a swizzled component looks unstyled, a missing StyleX compiler is almost always why. The same applies if you author your own StyleX with stylex.create().
| Bundler | StyleX plugin |
|---|---|
| Vite / SvelteKit | @stylexjs/unplugin (the paved path here) |
| Rollup | @stylexjs/rollup-plugin |
| Webpack | @stylexjs/webpack-plugin |
| Babel (any bundler) | @stylexjs/babel-plugin + @stylexjs/postcss-plugin |
The sharp edge here is not the bundler, it is the file extension. **StyleX may not be imported from a .svelte file.** The plugin Babel-parses every module that imports @stylexjs/stylex, and a Svelte component body is not JSX — so the build either fails with a parse error inside your markup or, worse, the module is routed around the plugin and the page renders unstyled with no error at all. Keep every stylex.create call in a .ts (conventionally <name>.stylex.ts) and import the resulting object.
tsimport { sveltekit } from '@sveltejs/kit/vite';import styleX from '@stylexjs/unplugin/vite';import { defineConfig } from 'vite';export default defineConfig({plugins: [styleX(), sveltekit()],// The published package ships .stylex.js UNCOMPILED, so Vite's pre-bundler// and the SSR externaliser must be told to route it through the plugin.optimizeDeps: { exclude: ['@astryx-svelte/core'] },ssr: { noExternal: ['@astryx-svelte/core'] }});
- Symptom of a missing compiler: a swizzled component renders with no styles, but no build or runtime error.
- Symptom of a missing optimizeDeps/ssr entry: the same silence, but only for styles that come from the package rather than from your own source.
- Never import @stylexjs/stylex from a .svelte file. Author styles in a .stylex.ts sibling.
- Pure theming (defineTheme + astryx-svelte theme build) needs NO StyleX compiler; only swizzled/authored StyleX source does.
What NOT to Do #
| Guidance | Practices |
|---|---|
| Don't | style="…" on raw <div> wrappers. Use xstyle on the component directly. |
| Don't | Hardcoded colors (#fff, rgb(...)). Use var(--color-*) tokens or Tailwind semantic classes (text-primary, bg-surface). |
| Don't | Hardcoded spacing (16px, 1rem). Use var(--spacing-*) tokens or Tailwind spacing utilities (p-4, gap-3). |
| Don't | Wrapping a component in a <div> just to add margin. Use xstyle with stylex.create on the component. |
| Don't | Using !important. If styles aren't applying, check specificity and cascade layers; xstyle is merged last. |
| Don't | Expecting a scoped <style> rule to reach inside a component. Svelte scopes it to the file that declares it; use :global(...) or xstyle. |