Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Radio Group
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsSelectionRadio Group

Radio Group

Scan with Expo Go

Radio Group with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

The dot springs in with a ripple; cards light up and draw a check badge.

<RadioGroup />5 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
options

The choices. `hint` is trailing text such as a price.

{ value, label, description?, icon?, hint?, disabled? }[]—
value / onValueChange

Controlled selection (or use defaultValue).

string—
defaultValue

Initial selection when uncontrolled.

string—
variant

Radio dots, selectable cards or a grouped list with ticks.

"default" | "card" | "segmented-list""default"
direction

Stack or lay out side by side (cards share the width).

"vertical" | "horizontal""vertical"
accent

Selected colour.

stringtheme accent
haptics

Selection tick on change.

booleantrue
disabled

Disable the whole group (or per option).

booleanfalse
style

Style for the group container (the bordered card in the segmented-list variant).

StyleProp<ViewStyle>—

Behaviour & accessibility#

Touch
Rows squish on press in the default and card variants, and the default dot has extra hitSlop. Disabled options are dimmed and ignore taps.
Motion
The selected dot pops on a bouncy spring with a ripple ring. Cards tint their border and pop a self-drawing check; the list variant draws a tick.
Haptics
A selection tick plays on each change (haptics, on by default).
Screen readers
The group is a radiogroup and each option a radio with checked and disabled state. Descriptions are read as hints.

Source#

PreviousColor SwatchNextRating

Did you like the content?

Coding agents

Chat apps

Other

9:41

Default

Radio Group

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg expo-haptics
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/radio-group.tsx328 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { RadioGroup } from "@/components/ui/radio-group";

    See Usage for a full example.

Example.tsx
import { RadioGroup } from '@/components/ui/radio-group'; <RadioGroup  defaultValue="standard"  options={[    { value: 'standard', label: 'Standard shipping', description: '4–6 business days · Free' },    { value: 'express', label: 'Express', description: '1–2 business days · $9' },  ]}/>

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/radio-group.tsx
import * as Haptics from 'expo-haptics';import React, { useEffect, useRef, useState } from 'react';import { Animated, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';import Svg, { Path } from 'react-native-svg'; import { alpha, motion, readableOn, useKit } from '@/lib/kit'; const AnimatedPath = Animated.createAnimatedComponent(Path);const CHECK = 'M5 12.5l4.5 4.5L19 7.5';const CHECK_LEN = 24; export type RadioGroupVariant = 'default' | 'card' | 'segmented-list'; export interface RadioOption {  value: string;  label: string;  description?: string;  /** Leading icon or emoji (card and segmented-list variants). */  icon?: React.ReactNode;  /** Trailing text, e.g. a price ("₹499/mo"). */  hint?: string;  disabled?: boolean;} export interface RadioGroupProps {  options: RadioOption[];  /** Controlled selection. */  value?: string;  /** Initial selection when uncontrolled. */  defaultValue?: string;  onValueChange?: (value: string) => void;  variant?: RadioGroupVariant;  direction?: 'vertical' | 'horizontal';  /** Selected colour (default: theme accent). */  accent?: string;  disabled?: boolean;  /** Selection tick on change (default true). */  haptics?: boolean;  accessibilityLabel?: string;  style?: StyleProp<ViewStyle>;} /** * Single-choice list. The dot springs in with a ripple, cards light up their border and pop a * check badge that draws itself, and the grouped list draws a tick next to the chosen row. */export function RadioGroup({  options,  value,  defaultValue,  onValueChange,  variant = 'default',  direction = 'vertical',  accent,  disabled,  haptics = true,  accessibilityLabel,  style,}: RadioGroupProps) {  const { colors, radius } = useKit();  const tint = accent ?? colors.accent;  const [inner, setInner] = useState(defaultValue);  const selected = value ?? inner;  const horizontal = direction === 'horizontal';   const select = (next: string) => {    if (next === selected) return;    if (haptics) Haptics.selectionAsync().catch(() => {});    if (value === undefined) setInner(next);    onValueChange?.(next);  };   const list = variant === 'segmented-list';   return (    <View      accessibilityRole="radiogroup"      accessibilityLabel={accessibilityLabel}      style={[        horizontal ? styles.horizontal : styles.vertical,        variant === 'default' && { gap: horizontal ? 20 : 14 },        variant === 'card' && { gap: 10 },        list && [styles.list, { borderRadius: radius.lg, borderColor: colors.border, backgroundColor: colors.card }],        style,      ]}    >      {options.map((o, i) => (        <RadioItem          key={o.value}          option={o}          active={o.value === selected}          onPress={() => select(o.value)}          variant={variant}          horizontal={horizontal}          first={i === 0}          tint={tint}          disabled={disabled || o.disabled}        />      ))}    </View>  );} interface RadioItemProps {  option: RadioOption;  active: boolean;  onPress: () => void;  variant: RadioGroupVariant;  horizontal: boolean;  first: boolean;  tint: string;  disabled?: boolean;} function RadioItem({ option, active, onPress, variant, horizontal, first, tint, disabled }: RadioItemProps) {  const { colors, radius } = useKit();  // p: colours + svg (JS driver). dot: scale/opacity pops (native). press: press feedback (native).  // ripple: a ring that expands and fades when the item gets selected, 0 → 1 (native, rests at 1).  const p = useRef(new Animated.Value(active ? 1 : 0)).current;  const dot = useRef(new Animated.Value(active ? 1 : 0)).current;  const press = useRef(new Animated.Value(1)).current;  const ripple = useRef(new Animated.Value(1)).current;  const mounted = useRef(false);   useEffect(() => {    Animated.spring(p, { toValue: active ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();    Animated.spring(dot, { toValue: active ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();    if (active && mounted.current) {      ripple.setValue(0);      Animated.timing(ripple, { toValue: 1, duration: 560, useNativeDriver: true }).start();    }    mounted.current = true;  }, [active, p, dot, ripple]);   const rippleStyle = {    backgroundColor: alpha(tint, 0.35),    opacity: ripple.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),    transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [1, 2.3] }) }],  };   const pressTo = (to: number) =>    Animated.spring(press, { toValue: to, useNativeDriver: true, ...(to < 1 ? motion.snappy : motion.bouncy) }).start();   const fg = colors.foreground;  const muted = colors.mutedForeground;  const a11y = {    accessibilityRole: 'radio' as const,    accessibilityState: { checked: active, disabled: !!disabled },    accessibilityLabel: option.label,    accessibilityHint: option.description,    disabled,    onPress,  };   const text = (    <View style={styles.text}>      <Text numberOfLines={2} style={[styles.label, { color: fg }]}>        {option.label}      </Text>      {option.description ? <Text style={[styles.description, { color: muted }]}>{option.description}</Text> : null}    </View>  );   if (variant === 'card') {    const lit = (from: string, to: string) => p.interpolate({ inputRange: [0, 1], outputRange: [from, to], extrapolate: 'clamp' });    const hint = option.hint ? (      <Animated.Text style={[styles.hint, horizontal && styles.hintColumn, { color: lit(fg, tint) }]}>{option.hint}</Animated.Text>    ) : null;    return (      <Animated.View style={[horizontal && styles.flex, { opacity: disabled ? 0.5 : 1, transform: [{ scale: press }] }]}>        <Pressable {...a11y} onPressIn={() => pressTo(0.97)} onPressOut={() => pressTo(1)} style={styles.flexGrow}>          {/* Focus ring: a soft accent glow just outside the selected card. */}          <Animated.View            pointerEvents="none"            style={[styles.glow, { borderRadius: radius.lg + 4, borderColor: alpha(tint, 0.2), opacity: p }]}          />          <Animated.View            style={[              styles.card,              horizontal && styles.cardColumn,              {                borderRadius: radius.lg,                borderColor: lit(colors.border, tint),                backgroundColor: lit(colors.card, alpha(tint, 0.08)),              },            ]}          >            {option.icon ? (              <Animated.View style={[styles.cardIcon, { borderRadius: radius.md, backgroundColor: lit(colors.muted, alpha(tint, 0.16)) }]}>                {option.icon}              </Animated.View>            ) : null}            <View style={[styles.text, horizontal && styles.cardTextColumn]}>              <Text numberOfLines={2} style={[styles.label, { color: fg }]}>                {option.label}              </Text>              {option.description ? <Text style={[styles.description, { color: muted }]}>{option.description}</Text> : null}              {horizontal ? hint : null}            </View>            {horizontal ? null : hint}            <View style={[styles.badgeSlot, horizontal && styles.badgeCorner]}>              <View style={[styles.badgeRing, { borderColor: colors.input }]} />              <Animated.View pointerEvents="none" style={[styles.ripple, rippleStyle]} />              <Animated.View                style={[styles.badge, { backgroundColor: tint, opacity: dot, transform: [{ scale: dot }] }]}              >                <Svg width={14} height={14} viewBox="0 0 24 24">                  <AnimatedPath                    d={CHECK}                    stroke={readableOn(tint)}                    strokeWidth={3.2}                    strokeLinecap="round"                    strokeLinejoin="round"                    fill="none"                    strokeDasharray={CHECK_LEN}                    strokeDashoffset={p.interpolate({ inputRange: [0.3, 1], outputRange: [CHECK_LEN, 0], extrapolate: 'clamp' }) as unknown as number}                  />                </Svg>              </Animated.View>            </View>          </Animated.View>        </Pressable>      </Animated.View>    );  }   if (variant === 'segmented-list') {    return (      <Pressable        {...a11y}        style={({ pressed }) => [          styles.listRow,          horizontal ? styles.flex : null,          !first && (horizontal ? styles.dividerLeft : styles.dividerTop),          !first && { borderColor: colors.border },          { backgroundColor: pressed ? colors.muted : 'transparent', opacity: disabled ? 0.5 : 1 },        ]}      >        <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: alpha(tint, 0.08), opacity: p }]} />        {option.icon ? <View style={styles.listIcon}>{option.icon}</View> : null}        <View style={styles.text}>          <Animated.Text            numberOfLines={1}            style={[styles.label, { color: p.interpolate({ inputRange: [0, 1], outputRange: [fg, tint], extrapolate: 'clamp' }) }]}          >            {option.label}          </Animated.Text>          {option.description ? <Text style={[styles.description, { color: muted }]}>{option.description}</Text> : null}        </View>        <Svg width={20} height={20} viewBox="0 0 24 24">          <AnimatedPath            d={CHECK}            stroke={tint}            strokeWidth={2.8}            strokeLinecap="round"            strokeLinejoin="round"            fill="none"            strokeDasharray={CHECK_LEN}            strokeDashoffset={p.interpolate({ inputRange: [0, 1], outputRange: [CHECK_LEN, 0], extrapolate: 'clamp' }) as unknown as number}          />        </Svg>      </Pressable>    );  }   return (    <Pressable      {...a11y}      hitSlop={8}      onPressIn={() => pressTo(0.86)}      onPressOut={() => pressTo(1)}      style={[styles.row, horizontal && styles.rowCompact, { opacity: disabled ? 0.5 : 1 }]}    >      <Animated.View style={[styles.ringSlot, { transform: [{ scale: press }] }]}>        <Animated.View pointerEvents="none" style={[styles.ripple, rippleStyle]} />        <Animated.View          style={[            styles.ring,            {              borderColor: p.interpolate({ inputRange: [0, 1], outputRange: [colors.input, tint], extrapolate: 'clamp' }),              backgroundColor: p.interpolate({ inputRange: [0, 1], outputRange: [colors.card, tint], extrapolate: 'clamp' }),            },          ]}        >          <Animated.View style={[styles.dot, { backgroundColor: readableOn(tint), opacity: dot, transform: [{ scale: dot }] }]} />        </Animated.View>      </Animated.View>      {horizontal && !option.description ? (        <Text style={[styles.label, { color: fg }]}>{option.label}</Text>      ) : (        text      )}    </Pressable>  );} const styles = StyleSheet.create({  vertical: { flexDirection: 'column' },  horizontal: { flexDirection: 'row', flexWrap: 'wrap' },  flex: { flex: 1, minWidth: 0 },  flexGrow: { flexGrow: 1 },  row: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },  rowCompact: { alignItems: 'center' },  ring: { width: 22, height: 22, borderRadius: 11, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },  dot: { width: 8, height: 8, borderRadius: 4 },  ringSlot: { alignItems: 'center', justifyContent: 'center' },  ripple: { position: 'absolute', width: 22, height: 22, borderRadius: 11 },  text: { flex: 1, minWidth: 0 },  label: { fontSize: 15.5, fontWeight: '400', lineHeight: 21 },  description: { fontSize: 13, lineHeight: 18, marginTop: 2 },  hint: { fontSize: 16, fontWeight: '400', fontVariant: ['tabular-nums'] },  hintColumn: { marginTop: 8 },  card: { flexDirection: 'row', alignItems: 'center', gap: 14, borderWidth: 1.5, padding: 16, flexGrow: 1 },  glow: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4, borderWidth: 3 },  cardColumn: { flexDirection: 'column', alignItems: 'flex-start', gap: 10, paddingTop: 14 },  cardTextColumn: { flex: 0, alignSelf: 'stretch', paddingRight: 18 },  cardIcon: { width: 42, height: 42, alignItems: 'center', justifyContent: 'center' },  badgeSlot: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' },  badgeCorner: { position: 'absolute', top: 12, right: 12 },  badgeRing: { position: 'absolute', width: 22, height: 22, borderRadius: 11, borderWidth: 1.5 },  badge: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center' },  list: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' },  listRow: { flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 50, paddingHorizontal: 16, paddingVertical: 12 },  listIcon: { width: 24, alignItems: 'center' },  dividerTop: { borderTopWidth: StyleSheet.hairlineWidth },  dividerLeft: { borderLeftWidth: StyleSheet.hairlineWidth },});