Icons
The icons Apsara draws, how to replace any of them, and how to build your own.Apsara ships the 31 icons its own components draw. Each one is exported under a stable key, renders at 16×16, and can be replaced with a component of your own. Every one is a lucide drawing.
1<Flex gap={5} align="center">2 <SearchIcon />3 <ChevronDownIcon />4 <SuccessIcon />5 <WarningIcon />6 <CoPilotIcon />7</Flex>
Import an icon by name from @raystack/apsara/icons:
1import { SearchIcon, ChevronDownIcon } from '@raystack/apsara/icons'23<SearchIcon />
You pay only for the icons you import. A build that shows three icons ships three icons, not all 31.
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.
The set
31 icons
Need an icon that is not here? createIcon is public, so
build your own and it behaves like the ones above.
lucide draws over 1,700 icons and your app can reach all of them — Apsara
exports these 31 because they are the ones inside its components, which you
cannot reach any other way.
What each key draws
Most keys name the glyph, so the name tells you the shape: ChevronDownIcon,
XIcon, CheckIcon, PlusIcon, SearchIcon. The rest name the job, because
the drawing behind them is a choice rather than a shape you asked for:
| Key | Draws |
|---|---|
SortAscendingIcon / SortDescendingIcon | lucide ArrowUpNarrowWide / ArrowDownWideNarrow |
FilterIcon | lucide ListFilter |
DisplayIcon | lucide SlidersHorizontal |
SuccessIcon / WarningIcon / ErrorIcon | lucide CircleCheck / TriangleAlert / CircleX |
ClearIcon | lucide CircleX |
StopIcon | lucide Square |
CalendarIcon | lucide CalendarDays |
CoPilotIcon | lucide Sparkles |
A key is yours to rely on; the drawing behind it can change in a release. So
prefer the key that says the job — SortDescendingIcon — over one that repeats
lucide's own vocabulary.
Two keys can share a drawing. ErrorIcon and ClearIcon are both CircleX, so
they look identical until you override one. That is the point: the error status
of a Toast and the clear button of a Search field are different jobs, and you
can change one without the other.
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 a 1px stroke 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 stop working as soon as an icon is overridden with a component
from somewhere else. 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="<Key>". 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
<Theme icons> takes one object with two halves: components replaces a
drawing by key, and props applies to every icon.
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);89render(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'34const icons = {5 components: { XIcon: X, ChevronDownIcon: ChevronDown }6}78<Theme icons={icons}>9 <App />10</Theme>
Apsara then uses your component everywhere that icon appears — inside its own components too. The map is partial: a key you do not name keeps its default, so you never have to supply a complete set.
Props for every icon
props applies 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>78 <Theme icons={{ props: { strokeWidth: 1 } }}>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 — props flows through
React and re-renders the icons, and CSS does not.
Nesting
A nested <Theme> layers on the one above it, one key 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);67const 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);1213render(14 <Theme icons={{ components: { XIcon: Square, CheckIcon: Circle } }}>15 <Flex gap={7} align="center">
What an override costs
An overridden icon ships twice: your component, and the default it never draws. Overrides are resolved at runtime, so a bundler cannot know which defaults to drop.
Replacing all 31 costs about 3.6 kB gzipped of lucide that never draws. The cost is flat — it does not grow as you override more. If you want the default gone from the bundle entirely, import your own icon at the call site instead of overriding it.
Building your own icons
createIcon is public, so an icon Apsara does not ship behaves exactly like one
that does:
1// src/icons.ts — the app's single place for icons2import { createIcon } from '@raystack/apsara/icons';3import { Rocket, Trash2 } from 'lucide-react';45export const RocketIcon = createIcon('RocketIcon', Rocket);6export const TrashIcon = createIcon('TrashIcon', Trash2);
1import { RocketIcon } from '@/icons';23<RocketIcon />; // 16×16, strokeWidth 1.5, data-icon="RocketIcon"
You get the base props, data-icon, and one file to edit if you ever change
icon library.
The props half of <Theme icons> reaches your icons too, since they read the
same context. The components half is typed to the keys Apsara ships — to
change one of your own, edit the file above. You own it, so you do not need a
provider to reach into it.
API Reference
Theme props
Prop
Type
IconOptions
Prop
Type
IconProvider
<Theme> mounts this for you. Use it directly only if you want icon overrides
without a theme scope. It takes the two halves of IconOptions as flat props.
Prop
Type
createIcon
1function createIcon(name: string, Default: IconComponent): IconComponent;
Wraps a component as an Apsara icon: base props below the call site,
data-icon="<name>", and the provider props. name is any string —
IconName covers only what Apsara ships, and only those keys can be replaced
through <Theme icons>.
Types
IconProps is Omit<SVGProps<SVGSVGElement>, 'children'>: every SVG attribute
React accepts, minus children. In practice you pass width, height,
strokeWidth, className, style or color — see base props.
IconComponent is ComponentType<IconProps>, so any component with those props
can stand in for an icon. IconOverrides is
Partial<Record<IconName, IconComponent>>, and IconName is the union of the 31
keys, so a typo in an override map is a type error.
1import { createIcon } from '@raystack/apsara';2import type {3 IconComponent,4 IconName,5 IconOptions,6 IconOverrides,7 IconProps8} 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.tsx2'use client';34import { Theme } from '@raystack/apsara';5import { X } from 'lucide-react';67const icons = { components: { XIcon: X } };89export function Providers({ children }: { children: React.ReactNode }) {10 return <Theme icons={icons}>{children}</Theme>;11}
1// app/layout.tsx (Server Component)2import { Providers } from './providers';34export 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 31 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. 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 every component module, while
require('@raystack/apsara/icons') loads the icons and nothing else.