Styling
How to style and customize Apsara components in your application.Apsara uses vanilla CSS with CSS custom properties (tokens) for all styling. There is no runtime CSS-in-JS — styles are static, scoped, and predictable. This guide covers the recommended ways to customize components and build your own styled elements.
Using Design Tokens
Design tokens are CSS custom properties that adapt automatically to the active theme. Always use tokens instead of hard-coded values — this ensures your UI stays consistent and responds to light/dark mode changes.
1.card {2 color: var(--rs-color-foreground-base-primary);3 background: var(--rs-color-background-base-secondary);4 border: 1px solid var(--rs-color-border-base-primary);5 padding: var(--rs-space-5);6 border-radius: var(--rs-radius-3);7 box-shadow: var(--rs-shadow-feather);8 font-size: var(--rs-font-size-small);9}
See the Theme section for the complete token reference.
Customizing Components
With className
Every Apsara component accepts a className prop. Use it alongside tokens to add custom styles:
1import { Button } from "@raystack/apsara";23<Button className="my-button" variant="outline">4 Custom styled5</Button>
1.my-button {2 min-width: 200px;3 border-radius: var(--rs-radius-5);4}
With style Prop
For one-off adjustments, use the inline style prop:
1<Flex gap={4} style={{ maxWidth: 600, margin: "0 auto" }}>2 <Button style={{ flex: 1 }}>Full width</Button>3</Flex>
With Data Attributes
Apsara components built on Base UI expose data attributes that reflect component state. Use these for state-driven styling without JavaScript:
1/* Style a sidebar based on open/closed state */2.my-sidebar[data-open] {3 width: 240px;4}56.my-sidebar[data-closed] {7 width: 57px;8}910/* Style based on active state */11.my-nav-item[data-active="true"] {12 background: var(--rs-color-background-neutral-secondary);13}1415/* Animate entry and exit */16.my-overlay[data-starting-style],17.my-overlay[data-ending-style] {18 opacity: 0;19}
Common data attributes include data-open, data-closed, data-active, data-disabled, data-state, data-starting-style, and data-ending-style.
With data-slot
Every part a component renders carries a data-slot attribute — a stable, public identifier for that element. Use it to style inner parts that have no className prop of their own, without depending on Apsara's internal (hashed) class names:
1/* Tint the remove button inside every filter chip */2[data-slot="filter-chip-remove"] {3 color: var(--rs-color-foreground-danger-primary);4}56/* Make DataView loading rows taller */7[data-slot="data-view-list-loader-row"] {8 min-height: 56px;9}1011/* Hide the magnifier icon inside Search */12[data-slot="search"] [data-slot="search-input"] {13 padding-left: var(--rs-space-3);14}
Slot names follow one convention:
- kebab-case, prefixed with the component name:
filter-chip-label,data-view-list-row. - Subparts extend the prefix:
data-view-filter-summary-clear. - The same part keeps the same name everywhere it appears (loader cells in
DataView.Listreusedata-view-list-cell).
Slot names are covered by semver — renaming or removing one is a breaking change, so selectors written against them are safe to keep. This is different from state attributes like data-open above: state attributes tell you how an element currently is, data-slot tells you what it is.
Combine them freely:
1/* Only style rows of a clickable list while it is loading */2[data-slot="data-view-list-loader-row"][aria-busy="true"] {3 opacity: 0.6;4}
Slots also make reliable selectors for tests (querySelector('[data-slot="search-clear-button"]')) instead of reaching for hashed CSS-module classes.
Styling based on what a component contains
Because every part carries its own slot, a parent can change its own styling based on which children are present — no prop needed to signal it. CSS's :has() reaches into descendants:
1/* A filter bar that's tighter when it has no chips yet */2[data-slot="data-view-filters"]:has([data-slot="filter-chip"]) {3 gap: var(--rs-space-4);4}56/* Style a submit button differently only inside a form's footer */7[data-slot="dialog-footer"] [data-slot="button"] {8 width: 100%;9}
With Tailwind's arbitrary variants, the same thing reads as a utility class instead of a stylesheet rule:
1<Flex className="has-[[data-slot=filter-chip]]:gap-4">2 <DataView.Filters />3</Flex>
1<Dialog.Footer className="[&_[data-slot=button]]:w-full">2 <Button>Save</Button>3</Dialog.Footer>
This is the same technique as the state-based selectors above (data-[state=open]:...) — data-slot just gives you a target that's stable across releases instead of one tied to internal markup.
Coverage: every Apsara component exposes data-slot on every element it renders (data-table is the one exception — it's being phased out in favor of DataView). Each component's docs page lists its slot names.
Theming with Data Attributes
The Theme component sets data attributes on the root <html> element. Use these to conditionally style elements based on the active theme:
1/* Dark mode specific styles */2[data-theme="dark"] .custom-card {3 border-color: var(--rs-color-border-base-tertiary);4}56/* Traditional style variant */7[data-style="traditional"] .custom-heading {8 font-family: var(--rs-font-family-serif);9}
Available theme attributes:
| Attribute | Values |
|---|---|
data-theme | light, dark |
data-style | modern, traditional |
data-accent-color | indigo, orange, mint |
data-gray-color | gray, mauve, slate, sage |
Writing Component Styles
When building custom components in your application, follow these patterns used internally by Apsara:
Use CSS Modules for Scoping
CSS Modules prevent class name collisions and keep styles co-located with components:
1// status-card.tsx2import styles from "./status-card.module.css";34export function StatusCard({ status, children }) {5 return (6 <div className={`${styles.card} ${styles[`card-${status}`]}`}>7 {children}8 </div>9 );10}
1/* status-card.module.css */2.card {3 padding: var(--rs-space-4);4 border-radius: var(--rs-radius-3);5 border: 1px solid var(--rs-color-border-base-primary);6}78.card-success {9 border-color: var(--rs-color-border-success-emphasis);10 background: var(--rs-color-background-success-primary);11}1213.card-danger {14 border-color: var(--rs-color-border-danger-emphasis);15 background: var(--rs-color-background-danger-primary);16}
Use CVA for Variant Management
Apsara uses class-variance-authority (CVA) to manage component variants with type safety. This is recommended for components with multiple visual variations:
1import { cva, type VariantProps } from "class-variance-authority";2import styles from "./tag.module.css";34const tag = cva(styles.tag, {5 variants: {6 size: {7 small: styles["tag-small"],8 medium: styles["tag-medium"],9 },10 color: {11 neutral: styles["tag-neutral"],12 accent: styles["tag-accent"],13 },14 },15 defaultVariants: {16 size: "medium",17 color: "neutral",18 },19});2021type TagProps = VariantProps<typeof tag> & { className?: string };2223export function Tag({ size, color, className, children }: TagProps) {24 return <span className={tag({ size, color, className })}>{children}</span>;25}
CVA handles merging the base class, variant classes, and any custom className passed by consumers.
Best Practices
Use semantic tokens, not primitives. Prefer --rs-color-foreground-base-primary over --rs-neutral-12. Semantic tokens automatically adapt across themes; primitives do not carry semantic meaning.
Avoid hard-coded colors. Every color value should come from a token. This guarantees proper light/dark mode support and visual consistency.
Use spacing tokens for layout. Consistent spacing creates visual rhythm. Use --rs-space-* tokens for padding, margin, and gap values.
Keep specificity low. Use single class selectors. Avoid !important and deeply nested selectors that are hard to override.
Co-locate styles with components. Place .module.css files next to their component files. This makes it easy to find and maintain styles.