Upgrading
Breaking changes in each Apsara release, and the steps to move your app.
One section per release, newest first, with only the changes that need action from you. The full record of every release, features and fixes included, is on GitHub releases.
Unreleased: date filters compare whole days
DataView, DataTable and FilterChip read and compare dates as calendar
days, without dayjs. A date filter's control is
CalendarPreview instead of DatePicker.
Calendar, DatePicker and RangePicker are still exported and do not change.
1. Check how your backend reads stringValue
A date filter's stringValue is a day key, '2026-08-15'. It used to be local
midnight as a UTC instant, '2026-08-14T18:30:00.000Z' for a viewer in India.
value does not change.
1// Before2onTableQueryChange={query => {3 // '2026-08-14T18:30:00.000Z'4 const day = query.filters?.[0]?.stringValue?.slice(0, 10); // '2026-08-14'5}}67// After8onTableQueryChange={query => {9 const day = query.filters?.[0]?.stringValue; // '2026-08-15'10}}
If your backend took the date part of the old string, it read the day before for every viewer east of UTC. The new value fixes that. A backend that expects a full timestamp may read a date-only string differently, so test it.
Saved filters and URL parameters that hold an old timestamp still load, as the day that instant falls on in the viewer's time zone.
2. Move calendarProps to CalendarPreview props
FilterChip's calendarProps and DataTable's filterProps.calendar take
CalendarPreview props now. slotProps.popover and showCalendarIcon work as
before. slotProps.input takes CalendarPreview.Input props, which have no
value or defaultValue. onErrorChange still reports 'Invalid date', but
the error clears only when the typed text is valid or empty, or a date is
committed. Closing the popup does not clear it.
| Removed | Replacement |
|---|---|
dateFormat | formatValue(date, timeZone), which returns the label |
slotProps.calendar / calendarProps | minDate, maxDate, isDateUnavailable, defaultMonth, yearRange |
inputProps | slotProps.input |
popoverProps | slotProps.popover |
1// Before2<FilterChip3 columnType="date"4 calendarProps={{5 dateFormat: "YYYY-MM-DD",6 slotProps: { calendar: { disabled: { after: new Date() } } }7 }}8/>910// After11<FilterChip12 columnType="date"13 calendarProps={{14 formatValue: (date, timeZone) =>15 date.toLocaleDateString("en-CA", { timeZone }),16 maxDate: new Date()17 }}18/>
formatValue changes only the label. The input still reads typed text in the
default formats, such as 15 Aug 2026.
3. Handle a cleared date
A date filter can be cleared: click the selected day, or empty the input. The
chip then calls onValueChange with ''. In DataView and DataTable the
chip stays, and the filter stops matching until a date is picked again. If you
render FilterChip yourself, handle ''.
4. Expect different rows from some filters
These filters used to match by mistake, and now match differently:
- A date filter with no value used to filter to today. It is now dropped.
- A date filter passed in
queryused to filter to today. It now filters by its own date. - A date filter on a day that does not exist, such as
2026-02-30, used to roll over to 2 March. It is now dropped. - A row with no date used to be read as today, so it could match any operator,
depending on the filter day. It now matches only
neq. - A row holding a day that does not exist used to roll over to a real date. It
now matches only
neq, and the timeline does not draw it. - A row holding a numeric string or a boolean used to be read as a date, such
as the year 1792 for
'1786752000000'. It now matches onlyneq, and the timeline does not draw it.
2.0: Theme is rewritten
Theme used to put its tokens on <html> from an effect, so it could not
server-render and only one could exist per page. It now mounts them on an
element it renders, which makes a scope, a portalled popup and the root the same
component, and adds radius, scaling, panel background and reduced-motion
settings. See Theme.
The component keeps its name but not its props, so migrate the whole application at once rather than file by file.
1. Reshape the props
1// Before2<Theme3 defaultTheme="system"4 storageKey="theme"5 style="modern"6 accentColor="orange"7 grayColor="mauve"8 onThemeChange={(theme, resolved) => track(resolved)}9>10 <App />11</Theme>1213// After14<Theme15 persistKey="theme"16 defaultValue={{17 appearance: "system",18 accentColor: "orange",19 grayColor: "mauve"20 }}21 onValueChange={(value, changed) => {22 if (changed.appearance) track(value.appearance);23 }}24>25 <App />26</Theme>
persistKey now gates persistence rather than only naming it: without one,
nothing is stored. It also ignores the bare theme name the old Theme wrote, so
a returning user starts from the seed once.
2. Rename the props
| Removed | Replacement |
|---|---|
theme, forcedTheme | value.appearance |
defaultTheme | defaultValue.appearance |
accentColor, grayColor as flat props | defaultValue.accentColor, defaultValue.grayColor |
style | radius plus the --rs-font-* tokens |
onThemeChange | onValueChange |
enableSystem | appearance: "system" |
enableColorScheme | Handled by the stylesheet |
storageKey | persistKey |
themes, attribute, value as a name-to-attribute map | None. Arbitrary named themes are not supported |
ThemeProvider alias | Theme |
icons is unchanged, and ThemeSwitcher keeps its name.
style="modern" | "traditional" becomes a radius level plus a font pair:
defaultValue={{ radius: "large" }} with --rs-font-title and --rs-font-body
set in CSS. The data-style attribute and the Lora / Josefin Sans pairing it
selected are gone, along with the --rs-font-lora and --rs-font-josefin-sans
tokens.
3. Reshape the hook
useTheme keeps its name and returns a different shape.
| Removed | Replacement |
|---|---|
useTheme().theme / .setTheme / .resolvedTheme / .systemTheme | value / setValue / resolved / systemAppearance |
useTheme().themes / .forcedTheme / .style / .scopes | None |
useTheme({ storageKey }) | useTheme().root |
1// Before: force dark for a subtree2<Theme forcedTheme="dark"><Sidebar /></Theme>3// After4<Theme value={{ appearance: "dark" }}><Sidebar /></Theme>56// Before: flip the page theme from inside a scope7const { setTheme } = useTheme({ storageKey: "theme" });8// After9const { root } = useTheme();10root.setValue({ appearance: "dark" });
useTheme throws outside a provider instead of returning a no-op.
4. Move anything that reads tokens inside the provider
Tokens are no longer on <html>, so consumer CSS that declared custom
properties on :root from --rs-* values, and hand-rolled portals that
rendered outside the provider, have to move inside it. <html> no longer needs
suppressHydrationWarning.
1.6: lucide replaces the radix icons
Apsara used to draw its icons with @radix-ui/react-icons.
Since 1.6 it draws them with lucide, behind stable keys
you can replace one at a time.
Some icons look different, lucide-react is a new peer dependency, and the
names @raystack/apsara/icons exports have changed.
1. Install the peer dependency
1npm install lucide-react
The range is wide, >=0.500.0 <2.0.0, so your app picks the version — both the
0.x and 1.x lines satisfy it. If a lucide release changes a drawing you care
about, replace that one icon (step 5) rather than pinning the whole library.
2. Rename what you imported from @raystack/apsara/icons
That path used to export raw in-house SVG components. It now exports the 31 icons Apsara's components draw, as replaceable icon components. Twelve of the old names are gone.
| Removed name | Use instead | Appearance |
|---|---|---|
BellIcon | lucide Bell | Same glyph |
BellSlashIcon | lucide BellOff | Similar |
BuildingsFilledIcon | lucide Building2 | Solid becomes stroke |
CheckCircleFilledIcon | lucide CircleCheck | Solid becomes stroke |
CoinIcon | lucide Coins | Similar |
CoinColoredIcon | lucide Coins | Loses its color |
CrossCircleFilledIcon | lucide CircleX | Solid becomes stroke |
OrganizationIcon | lucide Building2 | Similar |
ResetIcon | lucide RotateCcw | Similar |
ShoppingBagFilledIcon | lucide ShoppingBag | Solid becomes stroke |
SidebarIcon | PanelLeftIcon, or lucide PanelLeft | Similar |
TriangleRightIcon | ChevronRightIcon | Solid triangle becomes a chevron |
Two names survive, both with a new drawing:
CoPilotIcon: lucideSparklesin place of the in-house solid sparkle.FilterIcon: lucideListFilterin place of the in-house solid funnel.
A raw lucide component draws 24×24 at strokeWidth={2}, so set
size={16} strokeWidth={1.5} at the call site to match the Apsara icons beside
it, or wrap it once with createIcon, which applies those for you:
1// src/icons.ts2import { createIcon } from '@raystack/apsara/icons';3import { Bell } from 'lucide-react';45export const BellIcon = createIcon('BellIcon', Bell);
3. Check the icons that changed shape
These are inside Apsara's own components, so they change without you touching a call site. Everything else is the same glyph in a different drawing style.
| Where | Before (radix) | After | What changed |
|---|---|---|---|
Sidebar collapse | ViewVerticalIcon | PanelLeftIcon | A different glyph |
Sidebar group toggle | TriangleDownIcon | ChevronDownIcon | A solid triangle becomes a chevron |
Menu and ContextMenu submenu marker | in-house TriangleRightIcon | ChevronRightIcon | A solid triangle becomes a chevron |
ChatPanel expand | SizeIcon | ExpandIcon | A different glyph |
ChatPanel minimize | MinusIcon | ShrinkIcon | A dash becomes the matched pair of ExpandIcon |
PromptInput stop | StopIcon | StopIcon (lucide Square) | Solid becomes stroke |
DataTable sort ascending | TextAlignTopIcon | SortAscendingIcon | A different glyph |
DataTable and DataView sort descending | TextAlignBottomIcon | SortDescendingIcon | A different glyph |
DataTable and DataView display settings | MixerHorizontalIcon | DisplayIcon | Similar |
DataTable and DataView filters | in-house FilterIcon | FilterIcon (lucide ListFilter) | A solid funnel becomes filter lines |
ChatPanel minimized bubble | in-house CoPilotIcon | CoPilotIcon (lucide Sparkles) | A solid sparkle pair becomes a stroked sparkle |
Two more are worth a look, though the glyph is nearly the same:
DatePickerandRangePickerdrawCalendarIcon, which is lucideCalendarDays, so the glyph has day marks inside it.Search's clear button andToast's error status drawCircleXin place of radixCrossCircledIcon.
4. Expect a 1px size change in some places
Every Apsara icon renders at 16×16 with strokeWidth={1.5}, which draws the
1px stroke of the design because lucide's viewBox is 24 units wide. The radix
icons were intrinsically 15×15.
- A call site that set no size grows from 15px to 16px.
- A call site that set a CSS class is unaffected, since CSS beats an SVG presentation attribute.
- A call site that set
width/heightexplicitly is unaffected, since your props are applied after Apsara's base values.
To change the size or the stroke of every icon at once, use the props half of
<Theme icons>:
1<Theme icons={{ props: { width: 20, height: 20, strokeWidth: 1.25 } }}>
5. If you want the radix appearance back
Apsara ships no radix preset, so register the radix icons yourself at <Theme>.
Keep @radix-ui/react-icons in your own dependencies and copy this map:
1'use client';23import {4 ArrowDownIcon,5 ArrowUpIcon,6 CalendarIcon,7 CheckCircledIcon,8 CheckIcon,9 ChevronDownIcon,10 ChevronLeftIcon,11 ChevronRightIcon,12 CopyIcon,13 Cross1Icon,14 CrossCircledIcon,15 DotsHorizontalIcon,16 ExclamationTriangleIcon,17 FileTextIcon,18 InfoCircledIcon,19 MagnifyingGlassIcon,20 MinusIcon,21 MixerHorizontalIcon,22 MoonIcon,23 PlusIcon,24 SizeIcon,25 StopIcon,26 SunIcon,27 TableIcon,28 TextAlignBottomIcon,29 TextAlignTopIcon30} from '@radix-ui/react-icons';31import { Theme, type IconOverrides } from '@raystack/apsara';3233const radixIcons: IconOverrides = {34 ArrowDownIcon: ArrowDownIcon,35 ArrowUpIcon: ArrowUpIcon,36 CalendarIcon: CalendarIcon,37 CheckIcon: CheckIcon,38 ChevronDownIcon: ChevronDownIcon,39 ChevronLeftIcon: ChevronLeftIcon,40 ChevronRightIcon: ChevronRightIcon,41 ClearIcon: CrossCircledIcon,42 CopyIcon: CopyIcon,43 DisplayIcon: MixerHorizontalIcon,44 EllipsisIcon: DotsHorizontalIcon,45 ErrorIcon: CrossCircledIcon,46 ExpandIcon: SizeIcon,47 FileTextIcon: FileTextIcon,48 InfoIcon: InfoCircledIcon,49 MinusIcon: MinusIcon,50 MoonIcon: MoonIcon,51 PlusIcon: PlusIcon,52 SearchIcon: MagnifyingGlassIcon,53 ShrinkIcon: MinusIcon,54 SortAscendingIcon: TextAlignTopIcon,55 SortDescendingIcon: TextAlignBottomIcon,56 StopIcon: StopIcon,57 SuccessIcon: CheckCircledIcon,58 SunIcon: SunIcon,59 TableIcon: TableIcon,60 WarningIcon: ExclamationTriangleIcon,61 XIcon: Cross1Icon62};6364export function Providers({ children }: { children: React.ReactNode }) {65 return <Theme icons={{ components: radixIcons }}>{children}</Theme>;66}
Three keys are not in the map, because radix has no equivalent: FilterIcon,
PanelLeftIcon and CoPilotIcon. All three were in-house SVGs before. Radix
MagicWandIcon is the nearest stand-in for CoPilotIcon if you want one.
You do not have to take the whole map. A partial map changes only the keys it names.
6. Register from a client component
An override map is an object of functions, and a function cannot cross the
boundary from a React Server Component to a Client Component. If your <Theme>
sits directly in a server layout today, move it into a providers.tsx file
marked 'use client', as shown above.
This applies to any runtime icon override.
See Icons for the full set, the override API, and what each key draws.
1.0: Base UI replaces Radix and Ariakit
Apsara 1.0 rebuilds every component on Base UI in place of Radix UI, Ariakit, sonner and cmdk. It is the largest migration Apsara has had, and it has a dedicated guide: the v1 migration guide walks through every component with before-and-after examples. The headlines:
- React 19 is required. The peer range narrows from
^18 || ^19to^19. asChildbecomesrender. The Radix composition pattern is gone across every trigger and primitive; children move onto the wrapper.- Callbacks gain a second argument.
onValueChange,onOpenChangeandonCheckedChangenow receive aneventDetailsobject after the value. - Data attributes and CSS variables change.
data-state="open"becomesdata-open, and the--radix-*variables become Base UI names such as--anchor-width. Custom CSS that targets Apsara internals needs updating. - Form controls compose with
Field.InputFieldbecomesInput, and labels, descriptions and errors move to the newFieldwrapper. - Components are renamed.
DropdownMenubecomesMenuandSheetbecomesDrawer, each with prop renames of its own. - The pickers change contract.
DatePickerandRangePickergainslotProps, start unselected instead of defaulting to today, and require a realDate(orundefined) asvalue. - Layout scales go numeric.
FlexandGridtakegap={1}throughgap={17}in place of the named sizes, andHeadlinesizes becomet1–t4.
If you are coming from 0.x, work through the full guide in order: the cross-cutting changes first, then your components one by one.