Skip to content
Native Animation
Liquid Radio Group animation preview

This preview uses fictional demo content. See the props below for the customization options available in this component.

npx nativeanimation add liquid-radio-group
Skia

Liquid Radio Group

React Native Radio Buttons Group with Liquid Selection

A radio group with a liquid selection dot and expanding descriptions, with configurable options, labels, palette, and fonts.

$9 for this animation

Add to your project

Copy the component and its assets into your app with the Native Animation CLI, or paste the agent prompt into Claude Code, Codex or Cursor once the MCP server is connected.

About this animation

This React Native radio buttons group stores one form value in a vertical list of labeled options. A liquid dot travels along a dedicated rail while the selected option’s measured description expands in normal layout. The host can accept or reject controlled value requests, or use uncontrolled state and the select/reset ref methods. Options support stable IDs, descriptions, metadata, and disabled states. Radio and radiogroup semantics report the accepted value, while keyboard access and large touch targets provide direct alternatives to the visual effect. Reduced Motion sets final geometry immediately. Choose it for a visible single-choice form group; the published API supports vertical layout and requires Skia initialization on web.

When to use it

  • Choose one visible form option.
  • Reveal details beside the selected radio.
  • Keep disabled choices visible without allowing selection.

Related guides

A radio group with a liquid selection dot and expanding descriptions, with configurable options, labels, palette, and fonts.

How it works

  1. Each header and description measures its natural layout. Descriptions stay in document flow through an animated height viewport, so longer copy and larger text push later rows down.
  2. One selectionProgress shared value (named progress in the source) drives expansion weights, dot position, deformation, the neck, and settling. Press feedback uses independent shared values.
  3. A retarget snapshots all current expansion weights and liquid geometry in one UI-thread transaction. Restarting progress therefore preserves the current position and shape instead of returning to an old row.
  4. The dot compresses over 80 ms, travels over 160 ms, and settles with an explicit mass-1 spring. The neck closes before the small trailing surface retracts. There is one Canvas for the group, with a radial halo confined to the left rail (right in RTL).
  5. Selection semantics update once, when a request is accepted by uncontrolled state or supplied by a controlled host. Animation completion never calls onValueChange.
  6. Reduced motion sets the final geometry immediately, removing the traveling neck. The component subscribes to changes while mounted.

Install

npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets

Copy this folder without demo/. Use Expo's SDK-compatible versions. This implementation targets Expo SDK 58, React 19.2, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Skia 2.14.0. No Gesture Handler wrapper is required: this is a tap control, with no Pan competing against Pressable.

For web, initialize CanvasKit before importing the component, following Skia's web setup. A host can lazily import this module after LoadSkiaWeb({ locateFile: () => '/canvaskit.wasm' }) resolves. The host supplies the matching CanvasKit WASM file. Native platforms do not need this web initialization.

Usage

import { LiquidRadioGroup } from './liquid-radio-group';

const options = [
  { id: 'short', label: 'Short', description: 'A brief pause.' },
  { id: 'long', label: 'Long', description: 'A little more time.' },
];

// The root owns scrolling inside a bounded host; the host owns safe areas.
<View style={{ height: 400 }}>
  <LiquidRadioGroup
    options={options}
    defaultValue="short"
    labels={{
      group: 'Pause duration',
      empty: 'No durations available.',
      unavailable: 'Choose an available duration.',
    }}
    onValueChange={(id) => saveDuration(id)}
  />
</View>;

Controlled value, theme, motion knobs, and public handle:

const control = useRef<LiquidRadioGroupHandle>(null);
const [value, setValue] = useState('short');

<LiquidRadioGroup
  ref={control}
  options={options}
  value={value}
  onValueChange={setValue}
  colorScheme="dark"
  palette={{ accent: '#67E3CD', focus: '#67E3CD' }}
  dotSize={22}
  connectorWidth={10}
  motion={{ durationScale: 1.2, glowOpacity: 0.2 }}
/>;

control.current?.select('long');
control.current?.reset();

Import LiquidRadioGroupHandle from the folder for the second example. Use your app's font-family names through fontFamily, titleFontFamily, and monoFontFamily. The demo imports Geist and Instrument Serif from the host theme; these are not component dependencies.

For use inside an outer ScrollView, set scrollable={false} and allow natural height with style={{ flex: 0, flexBasis: 'auto', flexShrink: 0 }}. The group then expands fully and the outer host owns the scroll. In a bounded container, keep the default flex: 1 so the group's internal ScrollView remains reachable. The component adds 16-point gutters for its material shadow; account for these when aligning the panel to surrounding text.

Props

PropTypeDefaultBehavior
optionsreadonly RadioOption[]Required{ id, label, description?, meta?, disabled? }. IDs must be unique, nonempty strings.
valuestringUncontrolledA supplied value always wins. Unknown values leave every radio unchecked and display labels.unavailable.
defaultValuestringFirst enabled optionInitializes internal state. Invalid defaults intentionally leave no selection.
onValueChange(id: string) => voidNoneOne call per distinct change request. Controlled hosts may accept or reject the request.
orientation'vertical'VerticalOnly vertical layout is supported in v1.
labels{ group; empty; unavailable }Options / neutral helpAll group/help copy is replaceable.
disabledbooleanfalseBlocks touch, keyboard navigation, and select, retaining selection.
dotSizenumber20Finite values clamp to 16–28 points; NaN/Infinity use the default.
connectorWidthnumber8Finite values clamp to 2–14 points; NaN/Infinity use the default.
colorScheme'light' | 'dark' | 'auto''auto'Complete Paper/Night themes, including text and controls.
palettePartial<Palette>Theme tokensOverrides background, surface, elevated, text, muted, border, accent, onAccent, accentSoft, glow, success, error, scrim, track, focus, highlight, shadowNear, and shadowFar. The standalone form paints its panel; its host owns the surrounding background.
fontFamilystringSystemBody font, 16/24.
titleFontFamilystringBody fontControl labels, 16/24.
monoFontFamilystringBody fontMetadata, 18/26. No font weights are forced.
motion{ durationScale?; glowOpacity? }1, 0.25Finite values clamp to 0.5–2 and 0–0.3; invalid numbers use defaults.
styleStyleProp<ViewStyle>flex: 1Root overrides applied last. No safe-area padding.
scrollablebooleantrueSet to false for natural-height content without an internal ScrollView. The host then owns overflow.
testIDstringliquid-radio-groupStable controls: ${testID}-option-${id}.
accessibilityLabelstringlabels.groupAccessible group name.

select(id) requests an enabled option; unknown, disabled, and already-selected IDs do nothing. reset() requests the default (or first currently enabled option when no default was supplied), and is allowed while the group is disabled. An explicitly disabled default cannot be selected by reset. In controlled mode both methods request through the callback and never replace the host's value. Reset also cancels the running transition; there are no completion callbacks to invalidate.

Duplicate IDs retain the first entry. Empty IDs are dropped. An empty list displays labels.empty, with no selection. If data removes the current option, the component leaves selection empty rather than silently choosing another form value. Adding data to an initially empty uncontrolled group does not manufacture a selection; use select or a controlled value. A controlled value may retain a disabled option, but no action can newly select it.

Compatibility

  • Expo Go includes the installed Skia/Reanimated runtimes for SDK 58; no extra native module was added. Compilation does not establish native FPS or touch behavior.
  • Raster assets and density budgets: N/A. All form geometry is vector; no external art is loaded.

Accessibility

Rows expose radio, checked, disabled, their label/metadata, and the description as a hint. The parent exposes radiogroup. Empty/invalid-value help is accessible. Selection is also indicated by a filled dot and a side marker, rather than color alone. A polite web live region and a discrete native announcement use the supplied option copy; nothing announces per frame.

Tab reaches enabled rows. Enter/Space explicitly select the focused radio on web. Arrow keys move and select among enabled rows, wrapping at the ends; Home/End choose the first/last enabled option. Horizontal arrow meaning follows RTL. There is no popover or keyboard trap, so Escape requires no custom dismissal. Focus remains on the row being operated.

Each row has at least 76 points of height. Text is not truncated and scaling is enabled. The detail viewport grows from actual measured content; constrained hosts scroll. Disabled rows cannot be selected through the ref or keyboard. Reduced motion removes travel, deformation, and springs while retaining all content and actions. Consumer palette overrides must preserve contrast.

Why this one

This is a form value, not a tab navigator or horizontal segmented control. The dot transfers through a dedicated vertical rail while contextual detail opens at the selected row. Code, layout, demo copy, and identity are original; the external visual reference was used only for the liquid-selection pattern. No reference assets are distributed.

The demo's Tidewell name and droplet glyph come from the approved brand kit. The exact Lucide droplet path is ISC licensed (Lucide contributors); the host's demo fonts use SIL OFL. Skia uses MIT, and Reanimated/Worklets use MIT. Preserve their package license notices. The catalog's distribution license is determined by the host project; this README does not grant a separate license.

The demo loops Easy → Steady → Deep → Easy every 1.4 seconds in autoplay and Replay. Touch or pointer interaction stops the loop. At standard phone heights it uses natural-height views with compact spacing; short hosts use one outer ScrollView with a hidden indicator. Confirmation replaces the footer text without increasing content height.

FAQ

Can I control the selected radio value?
Yes. Supply value with onValueChange, or use defaultValue for local state. An unknown controlled ID leaves every option unchecked and displays the configured unavailable help.
Can disabled options be selected through the ref?
No. Touch, keyboard actions, and ref selection all reject disabled options. A controlled host may retain an already disabled value, but user actions cannot newly choose it.
Does the group support keyboard input and Reduced Motion?
Yes. On web, Enter/Space selects, arrows move and select enabled options, and Home/End choose endpoints. Rows expose radio and checked semantics. Reduced Motion removes travel and deformation.