Skip to content

Instantly share code, notes, and snippets.

@jeremy-code
Created May 12, 2026 05:55
Show Gist options
  • Select an option

  • Save jeremy-code/e3180502889a6a8ecb77eb22bfdabd74 to your computer and use it in GitHub Desktop.

Select an option

Save jeremy-code/e3180502889a6a8ecb77eb22bfdabd74 to your computer and use it in GitHub Desktop.
Switching from Radix UI to React Aria

Switching from Radix UI to React Aria

Differences

No <ScrollArea>

Radix UI offers a custom <ScrollArea> implementation both as its own component and in some of its components (e.g. Select).

Per adobe/react-spectrum#7286, React Aria maintainers claim that due to accessibility issues and being unable to adhere to user system preferences, they don't intend on supporting a customizable scroll area. Per this tweet, it seems maintainer Devon Govett strongly dislikes overriding user preferences.

This seems reasonable IMO given Radix cites in its own documentation: "In most cases, it's best to rely on native scrolling and work with the customization options available in CSS." Also, MDN claims definitively: "It is always best to use native scroll bars."

renderProps and composeRenderProps

For any components with renderProps (e.g. isDisabled, isPressed, etc.), the className, style, and children props will be of type string | ((renderProps) => string), CSSProperties | ((renderProps) => CSSProperties), ReactNode | ((renderProps) => ReactNode) respectively. Hence, you will need to use the composeRenderProps helper to be able to get the actual computed value instead of the function if you intend on supporting function props.

It's a bit confusing, but it can be useful. Probably the most annoying bit are the various TypeScript errors that have to be resolved by either making code very verbose or overrriding the default types.

The use case used in the Tailwind CSS documentation is passing the renderProps as variants to tailwind-variants, which does make the code a bit cleaner due to the lack of redundant variant prefixes.

The documentation's Tailwind CSS starter has a helper composeTailwindRenderProps, but here's a minor tweak I recommend for slightly better syntax:

import { composeRenderProps } from "react-aria-components/composeRenderProps";
import { twMerge, type ClassNameValue } from "tailwind-merge";

const composeTailwindRenderProps = <T>(
  className: string | ((renderProps: T) => string) | undefined,
  tw: ClassNameValue,
): string | ((v: T) => string) => {
  return composeRenderProps(className, (className) => twMerge(tw, className));
};

export { composeTailwindRenderProps };

render prop instead of asChild

Radix primitives use Slot with an asChild prop to allow rendering as a different component. Following in the footsteps of Ariakit and Base UI, React Aria instead uses a render prop. Specifically, React Aria cites the following use cases: router links, animation libraries, and pre-styled components. It also has these restrictions:

  1. The rendered element must be the expected element type (e.g. if is expected, you cannot render an )
  2. A single root DOM element can be rendered (no fragments).
  3. Props and ref must be passed to the underlying DOM element

The first restriction is one that is a bit unfortunate, because rendering links as buttons or vice versa is pretty common (React Spectrum, Adobe's component library based on React Aria, itself even has a LinkButton component).

Furthermore, some components (e.g. VisuallyHidden, Separator) offer an elementType prop.

Press events

You can see their rationale for adding press events here. They do alias onClick to onPress. One change is that press events by default stop propagation, so .stopPropagation() does not exist, and instead you will have to .continuePropagation() when desired. I do really like what this change means for styling (see Press events are AWESOME for styling).

Collections API

React Aria's Collections API is neat but was initially a bit confusing to me. For dynamic collections, instead of doing .map and setting keys, you pass a .items prop to the parent. If it has an id property, that will be its unique key. You can also set it manually via an id prop, but note this is NOT the same as the id attribute. The benefit of this is automatically memoization based on object equality.

Composition

Firstly, components can have a slot prop:

export interface SlotProps {
    /**
     * A slot name for the component. Slots allow the component to receive props from a parent component.
     * An explicit `null` value indicates that the local props completely override all props received from a parent.
     */
    slot?: string | null;
}

The idea being it allows users to avoid redundant code and keep code DRY.

For example, here is what the Dialog API looks like:

<DialogTrigger>
  <Button />
  <Dialog>
    <Heading slot="title" />
    <Button slot="close" />
  </Dialog>
</DialogTrigger>

Also, as shown in the above example, the naming seems to have the parent component named "ComponentTrigger" while the inner component is named "Component".

For form components like TextField, it seems that the intended use case is use it altogether as part of one large wrapper component, which I think is nice since it forces you to have proper labels and handle errors correctly.

Tooltips MUST be triggered by a focusable element

This also excludes disabled buttons.

Argos CI has a neat trick on how they handle it: using useFocusable in a custom TooltipTarget component.

The current advice seems to be, however, to use the <Focusable> element, which under the hood does nearly the same thing, although it merges refs.

@internationalized/number, @internationalized/date

Radix doesn't really handle numbers or dates, but since React Aria does, they use these packages (also by Adobe and under the react-spectrum umbrella) internally. It's not a big deal, but it's something to be aware of. For example, the Date/Time inputs expect @internationalized/date values for the value and onChange prop.

Misc. differences

  • Props like disabled, selected are instead called isDisabled and isSelected.
  • Context is exported when avaliable.
  • Alert Dialog is its own component in Radix UI, whereas React Aria seems to expect you to pass the Dialog the role of "alertdialog".
  • In the Dialog component in Radix UI, modal is a prop that can be enabled or disabled. In React Aria, Modal is its own component.

Improvements

Scrolling

One difference I am a big fan of is how they handle blocking scrolling. Radix uses react-remove-scroll while React Aria uses a custom implementation of usePreventScroll (see its usage here in useModalOverlay). Here are some bugs related to this issue in Radix: radix-ui/primitives#1159, radix-ui/primitives#3276. On their website, you can see a workaround they have to use for their QuickNav component in regards to removeScroll.

Interestingly, while React Aria's Select component does block scrolling, it seems their ComboBox component does not, though I am not sure whether or not this is intentional.

Significantly more inputs

Radix UI's most glaring omission IMO is the lack of a ComboBox. In comparison, React Aria not only has a ComboBox but also other inputs often neglected by component libraries like NumberField, DatePicker, TimeField, and ColorPicker, to name a few.

Press events are AWESOME for styling

I mentioned earlier their addition of press events. One of the larger pain points with Radix UI is using their Select component on mobile. While the UI is beautiful on desktop, since "hover" events don't really make sense on a touch screen, the component looks distractingly unresponsive. The following is React Aria, Radix UI, and the native select element on an iOS device.

export-v28-ea53

Of course, there are other advantages. For one, it's a bit annoying to see a large blue outline when focused on an input for example, but still wanting to allow a focusRing for those who focus on the element via keyboard. You can learn more about their approach to focus management here. I really appreciate being able to style components when pressed because it means that inputs are still highlighted when active, but not excessively in a distracting way.

image

Toast is AWESOME

Radix UI does have a Toast component but it's clearly meant to be used in a declarative way. Since usually toasts are used imperatively (such as in response to a form submission failing), libraries like sonner or react-hot-toast are commonly used, or the toasting function is implemented by scratch.

React Aria's Toast, while still in Alpha, really only has three unique components: ToastRegion, Toast, ToastContent, and a toastQueue, which handles the functionality. Being able to very simply just queue.add({ title: 'Update available' }) with no necessary setup besides a <ToastRegion /> is very convenient.

If you are looking to implement the "stacking" layout for toasts, React Spectrum and Hero UI both implement it.

Support

Regarding usage in a production scale (for more information see Examples of React Aria use in production), it is in use by Adobe themselves (more specifically, on some pages, they use React Spectrum), Unity, jetBlue, and Signal.

I opened two issues (adobe/react-spectrum#10013 and adobe/react-spectrum#10036) and received a response in a little over an hour.

Comparatively, while Radix is of course maintained by WorkOS, their last formal package release (v. 1.4.3) was nine months ago. Comparing their packages on Socket, Radix scores a 91 in Maintenance with 809 PRs + issues, compared to React Aria's 97 score with 589 PRs + issues.

Issues

These are more issues I faced rather than issues with the library itself that I figure would be good to document.

Be careful when using focusRing

In the React Aria Tailwind CSS docs, they recommend using a reusable variant called focusRing:

import { tv } from 'tailwind-variants';

export const focusRing = tv({
  base: 'outline outline-blue-600 dark:outline-blue-500 forced-colors:outline-[Highlight] outline-offset-2',
  variants: {
    isFocusVisible: {
      false: 'outline-0',
      true: 'outline-2'
    }
  }
});

Imagine for instance, you have something like this. While this example is a bit stupid, something similar can occur pretty easily if you forget to pass renderProps at some point in your Tailwind variants.

<button className={focusRing()}>span</button>

When that element is focused, there will be no visible indicator:

image

The reason I am guessing is that in Tailwind Variants being false is also the same as undefined (notice how outline-0 is applied).

Basically, just double check you pass renderProps whenever appropriate when using focusRing. You might want to consider updating it to use data attributes instead of variants.

Documentation

I would like to preface this with the note that in their latest release (a month ago), React Aria had consolidated all their packages under react-aria and react-stately, so I would not be surprised if that is relevant to why their documentation has some gaps. You can actually see the older react-aria docs and notice they're a lot more fully fleshed out compared to their components counterpart, though I am not sure how to get to that page from their homepage.

Still, it is a bit frustrating in regards to discoverability. For example, I don't think there is API docs for the Focusable and RouterProvider components, and it is a bit strange the Dialog documentation is under Modal.

Popover

There's this issue I've been experiencing with popovers on iOS Safari only (that I opened an issue for: adobe/react-spectrum#10036) where the height of the ComboBox popover is 0px in very specific circumstances. It's not a big deal, but it does highlight a choice of the package to handle positioning/overlays themselves (see their implementation of useOverlayPosition.ts). This surprised me somewhat since most libraries tend to opt for using Floating UI (formerly Popper) such as Base UI, Radix UI, Headless UI (which also uses react-aria for focus/interactions).

In my experience, the Popover experience has been fine. Still, given the difficulty of positioning, I think it's worth a mention.

The latest mention of replacing their positioning logic with floating-ui was in 2023 (adobe/react-spectrum#5426 (comment)). Per maintainer Devon Govett (adobe/react-spectrum#8173 (comment)) since May 2025, it seems they intend on moving to native CSS anchor positioning in the future, which is as of January 2026, baseline.

Missing components Radix UI has

The following Radix components don't have a one-to-one replacement in React Aria.

  • One-Time Password Field
  • Password Toggle Field
  • Context Menu
  • Scroll Area, Avatar, Aspect Ratio (functionality can be replaced with JS or CSS)
  • Navigation Menu, Hover Card (functionality can be replaced with Popover)

I don't expect them to be major losses, and push comes to shove, you can just pull in the components by themselves. Still, worth mentioning.


I think I like React Aria even though it was a bit annoying migrating to it from Radix. The quirks take getting used to, but they seemingly do have a purpose.

@jeremy-code

Copy link
Copy Markdown
Author

Another thing I noticed:

For both Select and ComboBox, value is Key | null | undefined and onChange gives you Key | null where Key is type Key = string | number, which is a bit annoying since it means you have to typeof value === "string" when working with them. Also, unlike Base UI's implementation of the ComboBox where the original object is returned in the onChange handler, only the key is provided for React Aria's ComboBox.

Also, a bit of trivia, Hero UI has two non React Aria-based dependencies, @radix-ui/react-avatar and input-otp.

@jeremy-code

Copy link
Copy Markdown
Author

Also! for fields, errorMessage only shows whenever isInvalid is true (when manually setting an error message)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment