Summary
I'm migrating an internal component library from @radix-ui/react-accordion to @radui/ui/Accordion. The visual API and anatomy match nicely, but two behavioral differences mean it isn't quite a drop-in swap. Both are areas where Rad UI diverges from the Radix API in ways that I believe are unintentional / fixable.
These are two independent concerns; I've written them as one issue with two sections, but happy to split into separate issues if maintainers prefer.
Versions
@radui/ui: 0.5.0
- React:
18.3.1
1. Content height animation is JS-driven and not stylable from CSS
Current behavior
Collapsible (used internally by Accordion.Content) animates the height by:
- Reading
scrollHeight via JS,
- Setting
style={{ height: 'Npx', transition: 'height <duration>ms <timing>' }} inline,
- Driving duration / timing via the
transitionDuration and transitionTimingFunction props on Accordion.Root.
Source (paraphrased from dist/components/index-Ir5FYyWW.js):
const style = {
overflow: 'hidden',
height: P !== undefined ? `${P}px` : undefined,
...(transitionDuration > 0
? { transition: `height ${transitionDuration}ms ${transitionTimingFunction}` }
: {}),
};
Why this is a problem
Radix exposes --radix-accordion-content-height (and --radix-accordion-content-width) on the content element, which lets consumers drive the animation entirely from CSS:
@keyframes slideDown {
from { height: 0; }
to { height: var(--radix-accordion-content-height); }
}
.AccordionContent[data-state='open'] { animation: slideDown 200ms ease-out; }
.AccordionContent[data-state='closed'] { animation: slideUp 200ms ease-out; }
Three downstream issues this creates for Rad UI today:
- Existing Radix-style CSS silently fights the inline transition. Any consumer keyframes targeting
[data-state] race against Rad UI's inline height + transition, producing janky animation. We had to add an explicit "disable keyframes" class on the content to opt out.
- No way to express stagger / different curves per state. With Radix, open and close can have different timing functions / keyframes. With Rad UI, both states share
transitionDuration / transitionTimingFunction from the Root.
prefers-reduced-motion isn't respected automatically. With CSS keyframes, @media (prefers-reduced-motion: reduce) { animation: none; } is one rule. With JS-driven inline transitions, consumers have to listen to the media query themselves and pass transitionDuration={0}.
Suggested fix
Expose the measured height as a CSS custom property on the content element, mirroring Radix:
--rad-accordion-content-height: <Npx>; (and --rad-accordion-content-width for horizontal orientation),
- and ideally make the inline
height / transition styles opt-out when the consumer is animating via CSS — e.g. when transitionDuration === 0 (already supported, but the inline height in steady state is still applied).
That would let consumers write:
@keyframes radSlideDown {
from { height: 0; }
to { height: var(--rad-accordion-content-height); }
}
…and migrate from Radix without touching their CSS.
2. value / defaultValue / onValueChange shape is the same for type="single" and type="multiple"
Current behavior
From dist/components/Accordion.d.ts:
type AccordionRootProps = ... & {
type?: 'single' | 'multiple';
collapsible?: boolean;
value?: (number | string)[];
defaultValue?: (number | string)[];
onValueChange?: (value: (number | string)[]) => void;
};
The shape is (number | string)[] regardless of type.
Why this is a problem
Radix discriminates the shape on type:
type |
value / defaultValue |
onValueChange |
'single' |
string |
(value: string) => void |
'multiple' |
string[] |
(value: string[]) => void |
With Rad UI, callers migrating from Radix have to add boilerplate even though type='single' semantically yields one value:
// Was (Radix):
<Accordion.Root type="single" onValueChange={(v) => setOpen(v)} />
// Now (Rad UI):
<Accordion.Root
type="single"
onValueChange={(v) => setOpen(v.length > 0 ? String(v[0]) : '')}
/>
This also defeats type narrowing — v is always (string | number)[], so the single case loses the static guarantee that there's at most one open item.
Suggested fix
Use a discriminated union on type, matching Radix:
type AccordionRootBaseProps = {
// shared props (orientation, disabled, transitionDuration, etc.)
};
type AccordionSingleProps = AccordionRootBaseProps & {
type?: 'single';
collapsible?: boolean;
value?: string | number;
defaultValue?: string | number;
onValueChange?: (value: string | number) => void;
};
type AccordionMultipleProps = AccordionRootBaseProps & {
type: 'multiple';
value?: (string | number)[];
defaultValue?: (string | number)[];
onValueChange?: (value: (string | number)[]) => void;
};
type AccordionRootProps = AccordionSingleProps | AccordionMultipleProps;
Internally the array representation can stay as-is — only the public types and the wrapper around onValueChange need to adapt.
If a hard break is undesirable, a non-breaking interim could be:
- Add a new
onValueChangeSingle?: (value: string | number | undefined) => void callback that fires only in single mode, keeping the array callback as the canonical one.
- Or accept
value: string | string[] and normalize internally, with the callback shape mirroring whatever the consumer passed.
Why this matters
Rad UI's docs page lists the same anatomy and props names as Radix, so adopters reasonably expect API compatibility. Closing these two gaps would make Rad UI a true drop-in replacement for @radix-ui/react-accordion and remove the per-component shim layer we've had to write.
Summary
I'm migrating an internal component library from
@radix-ui/react-accordionto@radui/ui/Accordion. The visual API and anatomy match nicely, but two behavioral differences mean it isn't quite a drop-in swap. Both are areas where Rad UI diverges from the Radix API in ways that I believe are unintentional / fixable.These are two independent concerns; I've written them as one issue with two sections, but happy to split into separate issues if maintainers prefer.
Versions
@radui/ui:0.5.018.3.11. Content height animation is JS-driven and not stylable from CSS
Current behavior
Collapsible(used internally byAccordion.Content) animates the height by:scrollHeightvia JS,style={{ height: 'Npx', transition: 'height <duration>ms <timing>' }}inline,transitionDurationandtransitionTimingFunctionprops onAccordion.Root.Source (paraphrased from
dist/components/index-Ir5FYyWW.js):Why this is a problem
Radix exposes
--radix-accordion-content-height(and--radix-accordion-content-width) on the content element, which lets consumers drive the animation entirely from CSS:Three downstream issues this creates for Rad UI today:
[data-state]race against Rad UI's inlineheight+transition, producing janky animation. We had to add an explicit "disable keyframes" class on the content to opt out.transitionDuration/transitionTimingFunctionfrom the Root.prefers-reduced-motionisn't respected automatically. With CSS keyframes,@media (prefers-reduced-motion: reduce) { animation: none; }is one rule. With JS-driven inline transitions, consumers have to listen to the media query themselves and passtransitionDuration={0}.Suggested fix
Expose the measured height as a CSS custom property on the content element, mirroring Radix:
--rad-accordion-content-height: <Npx>;(and--rad-accordion-content-widthfor horizontal orientation),height/transitionstyles opt-out when the consumer is animating via CSS — e.g. whentransitionDuration === 0(already supported, but the inlineheightin steady state is still applied).That would let consumers write:
…and migrate from Radix without touching their CSS.
2.
value/defaultValue/onValueChangeshape is the same fortype="single"andtype="multiple"Current behavior
From
dist/components/Accordion.d.ts:The shape is
(number | string)[]regardless oftype.Why this is a problem
Radix discriminates the shape on
type:typevalue/defaultValueonValueChange'single'string(value: string) => void'multiple'string[](value: string[]) => voidWith Rad UI, callers migrating from Radix have to add boilerplate even though
type='single'semantically yields one value:This also defeats type narrowing —
vis always(string | number)[], so thesinglecase loses the static guarantee that there's at most one open item.Suggested fix
Use a discriminated union on
type, matching Radix:Internally the array representation can stay as-is — only the public types and the wrapper around
onValueChangeneed to adapt.If a hard break is undesirable, a non-breaking interim could be:
onValueChangeSingle?: (value: string | number | undefined) => voidcallback that fires only in single mode, keeping the array callback as the canonical one.value: string | string[]and normalize internally, with the callback shape mirroring whatever the consumer passed.Why this matters
Rad UI's docs page lists the same anatomy and props names as Radix, so adopters reasonably expect API compatibility. Closing these two gaps would make Rad UI a true drop-in replacement for
@radix-ui/react-accordionand remove the per-component shim layer we've had to write.