Deft
Ask AI

Search

Search skills, authors and topics

migrate-radix-to-base

Migrate React projects and shadcn components from Radix UI to Base UI, handling route objects, consumer props, and progressive wrapper migration.

Install

npx skills add https://github.com/shadcn-ui/ui --skill migrate-radix-to-base

Radix UI to Base UI props mapping: form controls

Scope: select, checkbox, radio-group, switch, slider. Sources: radix-ui.com primitives docs and base-ui.com /react/components/*.md (fetched 2026-07-02).

Global conventions that apply to every part below:

  • asChild (Radix, boolean, default false) -> render (Base UI, ReactElement | ((props, state) => ReactElement)). Every Base UI part accepts render, plus className/style as either plain values or state callbacks ((state) => ...).
  • Base UI parts that render interactive elements accept nativeButton (tells Base UI whether the render target is a native <button>), which has no Radix equivalent.
  • Callbacks fire with a second eventDetails argument ({ reason, event, cancel(), allowPropagation(), isCanceled, isPropagationAllowed, trigger }). eventDetails.cancel() replaces Radix's event.preventDefault() pattern for preventing default component behavior.
  • Radix data-state="x" tokens become presence attributes in Base UI (data-checked, data-unchecked, data-open, ...). Base UI adds Field-integration attributes everywhere (data-valid, data-invalid, data-dirty, data-touched, data-filled, data-focused) and animation attributes (data-starting-style, data-ending-style).
  • Radix dir props have no Base UI per-component equivalent, direction comes from DirectionProvider (or the dir HTML attribute).

select

Part mapping: Root -> Root, Trigger -> Trigger, Value -> Value, Icon -> Icon, Portal -> Portal, Content -> Portal > Positioner > Popup (split into three parts), Viewport -> List, Item -> Item, ItemText -> ItemText, ItemIndicator -> ItemIndicator, ScrollUpButton -> ScrollUpArrow, ScrollDownButton -> ScrollDownArrow, Group -> Group, Label -> GroupLabel (Base UI's Select.Label is a NEW part that labels the trigger, not groups), Separator -> Separator, Arrow -> Arrow.

Select.Root → Select.Root

Radix propType / defaultBase UI equivalentMigration note
defaultValuestring, no defaultdefaultValue: Value[] | Value | nullSame name, widened type. Values can be any type (objects supported), arrays for multiple.
valuestring, no defaultvalue: Value[] | Value | nullSame name, widened type. null means "no value" (placeholder shown).
onValueChange(value: string) => voidonValueChange: (value: Value[] | Value | null, eventDetails: Select.Root.ChangeEventDetails) => voidSignature changed: second eventDetails arg with reason ('trigger-press' | 'outside-press' | 'escape-key' | 'window-resize' | 'item-press' | 'focus-out' | 'list-navigation' | 'cancel-open' | 'none') and cancel().
defaultOpenboolean, no defaultdefaultOpen: boolean, default falseSame.
openboolean, no defaultopen: booleanSame.
onOpenChange(open: boolean) => voidonOpenChange: (open: boolean, eventDetails: Select.Root.ChangeEventDetails) => voidSignature changed: added eventDetails (same reason union as above). Radix Content's onEscapeKeyDown/onPointerDownOutside interception moves here (check eventDetails.reason === 'escape-key' / 'outside-press', call eventDetails.cancel() to keep open).
dir"ltr" | "rtl", no defaultdroppedUse Base UI DirectionProvider or the dir attribute on an ancestor.
namestring, no defaultname: stringSame (Base UI renders a hidden <input>).
disabledboolean, no defaultdisabled: boolean, default falseSame.
requiredboolean, no defaultrequired: boolean, default falseSame.

Base UI Select.Root renders no HTML element (Radix Root doesn't either).

Select.Trigger → Select.Trigger

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSee global conventions. Base UI Trigger renders <button> by default; nativeButton defaults to true here, set it to false when rendering a non-button via render.
(none)-disabled: booleanBase UI allows disabling just the trigger.

Select.Value → Select.Value

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
placeholderReactNode, no defaultplaceholder: React.ReactNodeSame name. Behavior change: Radix Value renders the selected Item's ItemText content; Base UI renders the raw value string unless you pass items on Root or a children function ((value) => ReactNode). If your item labels differ from values, supply items on Root or format via children.

Select.Icon → Select.Icon

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern. Base UI Icon exposes data-popup-open for rotate-when-open styling.

Select.Portal → Select.Portal

Radix propType / defaultBase UI equivalentMigration note
containerHTMLElement, default document.bodycontainer: HTMLElement | ShadowRoot | React.RefObject<...> | nullSame concept, type widened (accepts refs and ShadowRoot). Base UI Portal renders a <div> and accepts className/style/render.

Select.Content → Select.Portal > Select.Positioner > Select.Popup (moved/split)

Radix Content handled positioning, collision, and the panel in one part. In Base UI, positioning props live on Positioner, panel/focus props live on Popup, dismiss interception lives on Root.onOpenChange eventDetails.

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserender (on Positioner and/or Popup)Same pattern.
position"item-aligned" | "popper", default "item-aligned"alignItemWithTrigger: boolean on Positioner, default trueSignature changed: enum becomes boolean. "item-aligned" -> alignItemWithTrigger (true, the default), "popper" -> alignItemWithTrigger={false}. Base UI auto-disables it when space is insufficient or on touch input.
side"top" | "right" | "bottom" | "left", default "bottom"side: Side on Positioner, default 'bottom'Moved. Base UI adds 'inline-start' | 'inline-end' logical values. Only applies when alignItemWithTrigger is off (as with Radix popper mode).
sideOffsetnumber, default 0sideOffset: number | OffsetFunction on Positioner, default 0Moved, type widened (accepts a function of { side, align, anchor, positioner }).
align"start" | "center" | "end", default "start"align: Align on Positioner, default 'center'Moved. Default differs: Radix "start" vs Base UI 'center'. Pass align="start" explicitly to preserve Radix behavior.
alignOffsetnumber, default 0alignOffset: number | OffsetFunction on Positioner, default 0Moved, type widened.
avoidCollisionsboolean, default truecollisionAvoidance: CollisionAvoidance on PositionerSignature changed: boolean becomes a config object { side: 'flip' | 'shift' | 'none', align: 'flip' | 'shift' | 'none', fallbackAxisSide: 'start' | 'end' | 'none' }. avoidCollisions={false} ~ collisionAvoidance={{ side: 'none', align: 'none', fallbackAxisSide: 'none' }}.
collisionBoundaryBoundary, default []collisionBoundary: Boundary on Positioner, default 'clipping-ancestors'Moved, default differs (Radix default is the viewport, Base UI defaults to clipping ancestors).
collisionPaddingnumber | Padding, default 10collisionPadding: Padding on Positioner, default 5Moved, default differs (10 -> 5).
arrowPaddingnumber, default 0arrowPadding: number on Positioner, default 5Moved, default differs (0 -> 5).
sticky"partial" | "always", default "partial"sticky: boolean on Positioner, default falseSignature changed and semantics differ: Base UI sticky keeps the popup in the viewport after the anchor scrolls out of view. There is no "always" equivalent.
hideWhenDetachedboolean, default falsedropped (workaround)No prop. Style on data-anchor-hidden (present on Positioner when the anchor is hidden), e.g. [data-anchor-hidden] { visibility: hidden }.
onCloseAutoFocus(event: Event) => voidfinalFocus on PopupSignature changed: instead of preventing default in an event handler, pass finalFocus as boolean | RefObject | ((closeType: InteractionType) => boolean | void | HTMLElement | null). false = don't move focus (the preventDefault() equivalent), a ref/element = focus that.
onEscapeKeyDown(event: KeyboardEvent) => voidmoved to Root.onOpenChangeCheck eventDetails.reason === 'escape-key'; call eventDetails.cancel() to prevent close.
onPointerDownOutside(event: PointerEvent) => voidmoved to Root.onOpenChangeCheck eventDetails.reason === 'outside-press'; call eventDetails.cancel() to prevent close.

Select.Viewport → Select.List (renamed)

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderRenamed part, no other props on either side. Radix required <ScrollUpButton>/<Viewport>/<ScrollDownButton> as siblings inside Content; in Base UI, List and the scroll arrows are children of Popup.

Select.Item → Select.Item

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern. Also nativeButton (default false, Base UI Item renders a <div>).
valuestring, requiredvalue: any, default nullSame name, widened type (objects allowed, see Root isItemEqualToValue / itemToString*). null value marks the placeholder item.
disabledboolean, no defaultdisabled: boolean, default falseSame.
textValuestring, no defaultlabel: stringRenamed. Both drive typeahead text matching, defaulting to the item's text content.

Select.ItemText → Select.ItemText

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern. Note element change: Radix renders <span>, Base UI renders <div>.

Select.ItemIndicator → Select.ItemIndicator

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
(implicit conditional mount)-keepMounted: booleanBase UI unmounts when unselected by default, same as Radix. keepMounted keeps it in the DOM (Radix had no forceMount on select's ItemIndicator).

Select.ScrollUpButton / ScrollDownButton → Select.ScrollUpArrow / ScrollDownArrow (renamed)

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderRenamed parts. Base UI adds keepMounted: boolean (default false) to keep the arrow in the DOM while the popup is not scrollable. Base UI arrows do not render on touch input.

Select.Group → Select.Group

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame.

Select.Label → Select.GroupLabel (renamed)

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderRenamed. Do NOT map to Base UI Select.Label, which is a new part that labels the select trigger itself (rendered outside the popup).

Select.Separator → Select.Separator

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame. Base UI adds orientation: Orientation, default 'horizontal'.

Select.Arrow → Select.Arrow

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
widthnumber, default 10droppedSize the arrow with CSS; Base UI Arrow renders a <div> you fill with your own SVG.
heightnumber, default 5droppedSame as above.

Base UI only props worth knowing (select)

  • Root.multiple: boolean (default false): multi-select with Value[] values, no Radix equivalent.
  • Root.items: Record<string, ReactNode> | { label, value }[] | Group[], lets Select.Value render labels instead of raw values.
  • Root.isItemEqualToValue, Root.itemToStringLabel, Root.itemToStringValue: object-value support.
  • Root.modal: boolean (default true): scroll lock + outside pointer blocking; Radix select was always modal-ish, set modal={false} for non-modal behavior.
  • Root.readOnly, Root.autoComplete, Root.form, Root.inputRef, Root.id, Root.onOpenChangeComplete, Root.actionsRef ({ unmount() } for externally controlled exit animations), Root.highlightItemOnHover (default true).
  • New parts: Select.Backdrop (overlay under the popup), Select.Label (trigger label), Popup.finalFocus.
  • Positioner.anchor, Positioner.positionMethod ('absolute' | 'fixed'), Positioner.disableAnchorTracking.

Data-attribute mapping (select)

RadixBase UI
Trigger data-state="open" | "closed"Trigger data-popup-open (presence), plus data-pressed, data-popup-side
Trigger data-placeholderTrigger/Value data-placeholder (same)
Trigger data-disabledTrigger data-disabled (same), plus data-readonly, data-required, Field attrs
Content data-state="open" | "closed"Positioner/Popup data-open / data-closed (presence)
Content data-side (left/right/bottom/top)Positioner/Popup data-side (none/top/bottom/left/right/inline-start/inline-end)
Content data-alignPositioner/Popup data-align (same values)
Item data-state="checked" | "unchecked"Item data-selected (presence, no unchecked token)
Item data-highlightedItem data-highlighted (same)
Item data-disabledItem data-disabled (same)
(none)Popup/Backdrop/ItemIndicator/ScrollArrows data-starting-style / data-ending-style (animation hooks)
(none)Positioner data-anchor-hidden, ScrollArrows data-direction / data-visible

CSS variable mapping (select)

All Base UI vars are set on Select.Positioner (Radix set them on Content, popper mode only):

RadixBase UI
--radix-select-trigger-width--anchor-width
--radix-select-trigger-height--anchor-height
--radix-select-content-available-width--available-width
--radix-select-content-available-height--available-height
--radix-select-content-transform-origin--transform-origin

checkbox

Part mapping: Root -> Root, Indicator -> Indicator. Element change: Radix Root renders a <button> plus hidden input inside a form; Base UI Root renders a <span> plus hidden <input> always (use nativeButton + render to render a real button).

Checkbox.Root → Checkbox.Root

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSee global conventions, pair with nativeButton when rendering a <button>.
defaultCheckedboolean | 'indeterminate', no defaultdefaultChecked: boolean, default falseSignature changed: 'indeterminate' is no longer a checked value. Use the separate indeterminate: boolean prop.
checkedboolean | 'indeterminate', no defaultchecked: boolean + indeterminate: booleanSignature changed: split into two props. Radix checked="indeterminate" -> Base UI indeterminate (a checkbox can be indeterminate and unchecked/checked independently).
onCheckedChange(checked: boolean | 'indeterminate') => voidonCheckedChange: (checked: boolean, eventDetails: Checkbox.Root.ChangeEventDetails) => voidSignature changed: checked is always boolean, eventDetails added (reason: 'none'). Indeterminate transitions are managed by you via the indeterminate prop (or parent in a CheckboxGroup).
disabledboolean, no defaultdisabled: boolean, default falseSame.
requiredboolean, no defaultrequired: boolean, default falseSame.
namestring, no defaultname: stringSame.
valuestring, default "on"value: stringSame name. Radix documents the default as "on"; Base UI docs list no default but hidden-input submission matches native checkbox behavior ("on" when unset).

Checkbox.Indicator → Checkbox.Indicator

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
forceMountboolean, no defaultkeepMounted: boolean, default falseRenamed. Both keep the element in the DOM when unchecked (for animation). Base UI also renders the indicator when indeterminate.

Base UI only props worth knowing (checkbox)

  • Root.indeterminate: boolean (default false): the mixed state, decoupled from checked.
  • Root.parent: boolean + CheckboxGroup (new component, base-ui.com/react/components/checkbox-group): <CheckboxGroup value/defaultValue/onValueChange(string[], eventDetails)/allValues/disabled> provides shared state for a set of checkboxes and enables a parent "select all" checkbox. No Radix equivalent, new capability.
  • Root.readOnly: boolean, Root.uncheckedValue: string (value submitted when unchecked), Root.form: string, Root.inputRef, Root.id, Root.nativeButton.

Data-attribute mapping (checkbox)

RadixBase UI
data-state="checked"data-checked
data-state="unchecked"data-unchecked
data-state="indeterminate"data-indeterminate
data-disableddata-disabled (same)
(none)data-readonly, data-required, data-valid, data-invalid, data-dirty, data-touched, data-filled, data-focused (Field integration)
(none)Indicator data-starting-style / data-ending-style

No CSS variables on either side.


radio-group

Part mapping: Radix ships one RadioGroup namespace; Base UI splits it into RadioGroup (a single component, no sub-parts) and Radio (Radio.Root, Radio.Indicator). RadioGroup.Root -> RadioGroup, RadioGroup.Item -> Radio.Root, RadioGroup.Indicator -> Radio.Indicator. Element change: Radix Item renders <button>; Base UI Radio.Root renders <span> plus hidden <input> (use nativeButton + render for a real button).

RadioGroup.Root → RadioGroup

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
defaultValuestring, no defaultdefaultValue: ValueSame name, widened type (any value type).
valuestring, no defaultvalue: ValueSame name, widened type.
onValueChange(value: string) => voidonValueChange: (value: Value, eventDetails: RadioGroup.ChangeEventDetails) => voidSignature changed: added eventDetails (reason: 'none').
disabledboolean, no defaultdisabled: boolean, default falseSame.
namestring, no defaultname: stringSame.
requiredboolean, no defaultrequired: boolean, default falseSame.
orientationenum, default undefineddroppedBase UI arrow-key navigation handles both axes automatically; there is no orientation prop (set aria-orientation yourself if needed for AT).
dir"ltr" | "rtl", no defaultdroppedUse DirectionProvider.
loopboolean, default truedroppedFocus wrapping is built in and not configurable.

RadioGroup.Item → Radio.Root (moved to Radio namespace)

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern, plus nativeButton (default false).
valuestring, requiredvalue: Value, requiredSame name, widened type.
disabledboolean, no defaultdisabled: booleanSame.
requiredboolean, no defaultrequired: booleanSame.

RadioGroup.Indicator → Radio.Indicator (moved to Radio namespace)

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
forceMountboolean, no defaultkeepMounted: boolean, default falseRenamed.

Base UI only props worth knowing (radio-group)

  • RadioGroup.readOnly, RadioGroup.form, RadioGroup.inputRef (the group owns one hidden input).
  • Radio.Root.readOnly, Radio.Root.inputRef, Radio.Root.nativeButton.

Data-attribute mapping (radio-group)

RadixBase UI
Root data-disabledRadioGroup data-disabled (same)
Item/Indicator data-state="checked"Radio.Root/Indicator data-checked
Item/Indicator data-state="unchecked"Radio.Root/Indicator data-unchecked
Item/Indicator data-disableddata-disabled (same)
(none)data-readonly, data-required, Field attrs (data-valid, data-invalid, data-dirty, data-touched, data-filled, data-focused)
(none)Indicator data-starting-style / data-ending-style

No CSS variables on either side.


switch

Part mapping: Root -> Root, Thumb -> Thumb. Element change: Radix Root renders <button> + hidden input in forms; Base UI Root renders <span> plus hidden <input> always (use nativeButton + render for a real button).

Switch.Root → Switch.Root

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern, pair with nativeButton.
defaultCheckedboolean, no defaultdefaultChecked: boolean, default falseSame.
checkedboolean, no defaultchecked: booleanSame.
onCheckedChange(checked: boolean) => voidonCheckedChange: (checked: boolean, eventDetails: Switch.Root.ChangeEventDetails) => voidSignature changed: added eventDetails (reason: 'none').
disabledboolean, no defaultdisabled: boolean, default falseSame.
requiredboolean, no defaultrequired: boolean, default falseSame.
namestring, no defaultname: stringSame.
valuestring, default "on"value: stringSame name. Base UI submits "on" by default, matching native checkbox behavior.

Switch.Thumb → Switch.Thumb

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern, only prop on either side.

Base UI only props worth knowing (switch)

  • Root.readOnly: boolean, Root.uncheckedValue: string, Root.form: string, Root.inputRef, Root.id, Root.nativeButton (default false).

Data-attribute mapping (switch)

RadixBase UI
Root/Thumb data-state="checked"data-checked
Root/Thumb data-state="unchecked"data-unchecked
Root/Thumb data-disableddata-disabled (same)
(none)data-readonly, data-required, Field attrs (data-valid, data-invalid, data-dirty, data-touched, data-filled, data-focused)

No CSS variables on either side.


slider

Part mapping: Root -> Root, Track -> Track, Range -> Indicator (renamed), Thumb -> Thumb, plus a NEW required Control part: Base UI anatomy is Root > Control > Track > (Indicator, Thumb). Control is the clickable/draggable surface (Radix Root handled pointer interaction itself). Base UI also adds Value and Label parts. Element change: Radix Thumb renders a plain element wrapped by an invisible span with a hidden input in forms; Base UI Thumb renders a <div> with a nested <input type="range">.

Slider.Root → Slider.Root

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
defaultValuenumber[], no defaultdefaultValue: number | number[]Same name, widened: a single number gives a single-thumb slider (no array wrapper needed).
valuenumber[], no defaultvalue: number | number[]Same, widened. Ranged sliders still take an array.
onValueChange(value: number[]) => voidonValueChange: (value: number | number[], eventDetails: Slider.Root.ChangeEventDetails) => voidSignature changed: value matches the shape you pass in (number for single), eventDetails added with reason: 'input-change' | 'track-press' | 'drag' | 'keyboard' | 'none' and activeThumbIndex: number.
onValueCommit(value: number[]) => voidonValueCommitted: (value: number | number[], eventDetails: Slider.Root.CommitEventDetails) => voidRenamed (Commit -> Committed) and signature changed (same shape/eventDetails notes as above). Base UI does not fire it if the value did not change.
namestring, no defaultname: stringSame.
disabledboolean, default falsedisabled: boolean, default falseSame.
orientation"horizontal" | "vertical", default "horizontal"orientation: Orientation, default 'horizontal'Same.
dir"ltr" | "rtl", no defaultdroppedUse DirectionProvider.
invertedboolean, default falsedropped (workaround)No equivalent. For horizontal sliders, wrap in DirectionProvider dir="rtl" (direction-based inversion); there is no built-in way to invert a vertical slider.
minnumber, default 0min: number, default 0Same.
maxnumber, default 100max: number, default 100Same.
stepnumber, default 1step: number, default 1Same.
minStepsBetweenThumbsnumber, default 0minStepsBetweenValues: number, default 0Renamed (Thumbs -> Values).
formstring, no defaultform: stringSame.

Slider.Track → Slider.Track (moved inside Control)

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern. Structural move: Track must now be nested in the new Slider.Control part, and Thumb moves inside Track (Radix had Thumb as a sibling of Track under Root).

Slider.Range → Slider.Indicator (renamed)

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderRenamed part, same role (visualizes the filled portion), still a child of Track.

Slider.Thumb → Slider.Thumb

Radix propType / defaultBase UI equivalentMigration note
asChildboolean, falserenderSame pattern.
(per-thumb accessibility via aria props)-index: number, getAriaLabel(index), getAriaValueText(formattedValue, value, index), aria-valuetextBase UI thumbs take index (required for SSR of multi-thumb range sliders) and a11y formatters. Also disabled, inputRef, tabIndex, onFocus/onBlur/onKeyDown forwarded to the nested <input type="range">.

(new part) Slider.Control

No Radix equivalent. The interactive surface that receives pointer events; wrap Track with it. Props: className/style/render only.

Base UI only props worth knowing (slider)

  • Root.thumbAlignment: 'center' | 'edge' | 'edge-client-only' (default 'center'): whether the thumb center or edge aligns with the control edge at min/max. Radix always behaved like 'edge'-ish via CSS; Base UI defaults to 'center', set thumbAlignment="edge" to keep the thumb inside the track bounds.
  • Root.thumbCollisionBehavior: 'push' | 'swap' | 'none' (default 'push'): range-slider thumb collision handling (Radix behavior was closest to 'none').
  • Root.largeStep: number (default 10): Page Up/Down and Shift+Arrow increment.
  • Root.format: Intl.NumberFormatOptions and Root.locale: Intl.LocalesArgument: value formatting for Slider.Value and aria-valuetext.
  • New parts: Slider.Value (renders <output>, children: (formattedValues: string[], values: number[]) => ReactNode), Slider.Label (auto-associated label).

Data-attribute mapping (slider)

RadixBase UI
data-disabled (all parts)data-disabled (same, all parts)
data-orientation (horizontal/vertical, all parts)data-orientation (same values, all parts)
(none)data-dragging (present on all parts while dragging)
(none)Thumb data-index (thumb index in range sliders)
(none)Field attrs on all parts (data-valid, data-invalid, data-dirty, data-touched, data-focused)

No CSS variables on either side (Radix slider positions thumbs via inline styles; Base UI does the same).