Usage

How to import an Apsara icon, size and colour it, and replace any icon with your own component.

Apsara ships 243 icons. 239 are drawn by lucide, and 4 are in-house SVGs that lucide cannot supply. Every icon is exported under a stable name, and every icon can be replaced with your own component.

1<Flex gap={5} align="center">
2 <SearchIcon />
3 <ChevronDownIcon />
4 <CircleCheckIcon />
5 <TriangleAlertIcon />
6 <CoPilotIcon />
7</Flex>

Browse the set on All icons.

Anatomy

Import an icon by name from @raystack/apsara/icons:

1import { SearchIcon, ChevronDownIcon } from '@raystack/apsara/icons'
2
3<SearchIcon />

Each icon is its own module, so you pay only for the icons you import. A build that shows three icons ships three icons, not all 243.

The package root exports the same components, so import { SearchIcon } from '@raystack/apsara' works too — it is the natural choice in a file that already imports Apsara components. See which path to import from for the difference.

Naming

An icon name is the lucide name in Pascal case with the suffix Icon. So lucide circle-x is CircleXIcon, and list-filter is ListFilterIcon.

A name identifies a shape, not a role. Two roles that use one shape therefore share one name. Inside Apsara this happens once: CircleXIcon draws both the error icon of the Toast and the clear button of the Search field, so an override of CircleXIcon changes both.

Base props

Every icon renders with width={16} height={16} strokeWidth={1.5}. The rendered stroke is strokeWidth × size ÷ 24, because lucide draws in a 24-unit viewBox. So the default draws the 1px stroke of the design at 16px.

If you change the size and want to keep a 1px stroke, scale the stroke with it — strokeWidth={24 / size}.

All three are standard SVG attributes, so any icon library accepts them. A library that draws solid shapes simply ignores stroke-width.

Size

Pass width and height to change the size. A CSS class or style also wins, because CSS beats an SVG presentation attribute.

1<Flex gap={5} align="center">
2 <SearchIcon />
3 <SearchIcon width={20} height={20} />
4 <SearchIcon width={24} height={24} />
5 <SearchIcon width={32} height={32} strokeWidth={2} />
6</Flex>

Do not pass the lucide size or absoluteStrokeWidth props. They are specific to lucide, so they break as soon as an icon is overridden. absoluteStrokeWidth does nothing here in any case, because Apsara sets width/height rather than size.

Colour

An icon inherits currentColor, so set color on the icon or on an ancestor.

1<Flex gap={5} align="center">
2 <SearchIcon />
3 <SearchIcon style={{ color: "var(--rs-color-foreground-accent-primary)" }} />
4 <SearchIcon style={{ color: "var(--rs-color-foreground-danger-primary)" }} />
5 <SearchIcon style={{ color: "var(--rs-color-foreground-success-primary)" }} />
6</Flex>

The data-icon attribute

Every icon renders data-icon="<Name>". Use it to style a single icon from CSS without re-rendering anything, and to select an icon in a test:

1[data-icon='ChevronDownIcon'] {
2 color: var(--rs-color-foreground-base-tertiary);
3}
1expect(document.querySelector('[data-icon="XIcon"]')).toBeInTheDocument();

Replacing an icon

Give <Theme> a map of icon name to your own component. Apsara then uses your component everywhere that icon appears — inside its own components too.

1// A double chevron stands in for every ChevronDownIcon below.
2const MyChevron = (props) => (
3 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
4 <path d="m7 6 5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
5 <path d="m7 13 5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
6 </svg>
7);
8
9render(
10 <Flex gap={7} align="center">
11 <Flex direction="column" gap={3} align="center">
12 <Select defaultValue="apple">
13 <Select.Trigger style={{ width: 140 }}>
14 <Select.Value />
15 </Select.Trigger>
1import { Theme } from '@raystack/apsara'
2import { X, ChevronDown } from 'lucide-react'
3
4const icons = { XIcon: X, ChevronDownIcon: ChevronDown }
5
6<Theme icons={icons}>
7 <App />
8</Theme>

The map is partial: an icon you do not name keeps its default. You never have to supply a complete set.

You may pass the map as an inline object literal. Apsara compares its contents, so a new literal on every render costs nothing.

Props for every icon

iconProps applies props to every icon below the provider. The props at the call site still win.

1<Flex gap={7} align="center">
2 <Flex gap={4} align="center">
3 <SearchIcon />
4 <ChevronDownIcon />
5 <XIcon />
6 </Flex>
7
8 <Theme iconProps={{ strokeWidth: 1.5 }}>
9 <Flex gap={4} align="center">
10 <SearchIcon />
11 <ChevronDownIcon />
12 <XIcon />
13 </Flex>
14 </Theme>
15</Flex>

Prefer data-icon and CSS when a style rule is enough — iconProps flows through React and re-renders the icons, and CSS does not.

Nesting

A nested <Theme> layers on the one above it, one name at a time. So a subtree can change one icon and keep everything else it inherited.

1const Square = (props) => (
2 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
3 <rect x="5" y="5" width="14" height="14" rx="2" />
4 </svg>
5);
6
7const Circle = (props) => (
8 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
9 <circle cx="12" cy="12" r="7" />
10 </svg>
11);
12
13render(
14 <Theme icons={{ XIcon: Square, CheckIcon: Circle }}>
15 <Flex gap={7} align="center">

API Reference

Theme props

Prop

Type

IconProvider

<Theme> mounts this for you. Use it directly only if you want icon overrides without a theme scope.

Prop

Type

Types

Prop

Type

IconComponent is ComponentType<IconProps>, and IconOverrides is Partial<Record<IconName, IconComponent>>. IconName is the union of all 243 names, so a typo in an override map is a type error.

1import type {
2 IconComponent,
3 IconName,
4 IconOverrides,
5 IconProps
6} from '@raystack/apsara';

Server components

The icons are client components, and an icon map is an object of functions. A function cannot cross the boundary from a Server Component to a Client Component, so register the overrides from a client component:

1// app/providers.tsx
2'use client';
3
4import { Theme } from '@raystack/apsara';
5import { X } from 'lucide-react';
6
7const icons = { XIcon: X };
8
9export function Providers({ children }: { children: React.ReactNode }) {
10 return <Theme icons={icons}>{children}</Theme>;
11}
1// app/layout.tsx (Server Component)
2import { Providers } from './providers';
3
4export default function Layout({ children }) {
5 return (
6 <html>
7 <body>
8 <Providers>{children}</Providers>
9 </body>
10 </html>
11 );
12}

Resolution is a pure function of the context and the props — no localStorage, no window, no effect — so the server markup and the client markup are identical. There is no hydration mismatch and no flash of the wrong icon.

Which path to import from

Both entry points export the same 243 icons, as the same components. What differs is how much work a bundler has to do to strip the rest of Apsara.

1// The same component, either way.
2import { SearchIcon } from '@raystack/apsara/icons';
3import { SearchIcon } from '@raystack/apsara';

@raystack/apsara/icons reaches no component module at all, so one icon costs one icon whatever the bundler does. The package root re-exports every component alongside the icons; Apsara sets "sideEffects": false so a bundler that honours that flag strips them and the two paths come out identical, but a bundler that does not honour it keeps them all. Bundling a single icon against the published files gives:

Import fromBundler honours sideEffectsBundler does not
@raystack/apsara/icons2 modules, 1.5 kB2 modules, 1.5 kB
@raystack/apsara2 modules, 1.5 kB332 modules, 541 kB

Sizes are unminified. So the subpath is the safer default: identical in the good case, and far cheaper in the bad one. Use the root in files that already import components, where a second import line buys nothing.

CommonJS cannot tree-shake at all, so there the difference is unconditional: require('@raystack/apsara') loads 332 modules and every component, while require('@raystack/apsara/icons') loads 244 modules and no component.

What changed in this release

The subpath used to export raw SVG assets, which meant BellIcon from ./icons and BellIcon from the package root were two different components. It now re-exports the registry wrappers, so both give the same overridable component.

Every name it exports is now a registry key, so nine of the old in-house names are gone. Rename them:

Removed nameUse instead
BellSlashIconBellOffIcon
BuildingsFilledIconBuilding2Icon
CoinIconCoinsIcon
FilterIconListFilterIcon
OrganizationIconBuilding2Icon
ResetIconRotateCcwIcon
ShoppingBagFilledIconShoppingBagIcon
SidebarIconPanelLeftIcon
TriangleRightIconChevronRightIcon

The other five in-house names are registry keys already, so they keep working unchanged: BellIcon, CoPilotIcon, CoinColoredIcon, CheckCircleFilledIcon, and CrossCircleFilledIcon.