Skip to content
  • Introduction
    nativecn is shadcn for Expo apps: Components you copy into your app and own, built so AI agents can build and verify whole mobile apps.
  • Installation
    Create a new Expo app with nativecn, or add nativecn to an existing one.
  • components.json
    The project file that records your Preset, Structure, aliases and agents.
  • CLI
    Every nativecn-cli command and flag: init, create, add, agents and mcp.
  • Changelog
    What changed in each nativecn release.
  • Theming
    Tokens, Colour Roles, Scale, Scheme, createStyles and fonts.
  • Presets & Styles
    Style, Base Colour, Accent Colour, Radius and fonts: chosen once, baked into your code.
  • Folder structure
    Flat and feature Structures, Destinations and the placement rule.
  • AI agents
    Rules, Skills, the nativecn MCP server, the Plugin and the Visual QA Loop.
  • Alert
    Alert, AlertTitle and AlertDescription: an inline message with Variants (default, destructive, success, warning, info), each with a default icon.
  • Avatar
    Round user image (expo-image) with fallback initials, in sizes sm, md and lg.
  • Badge
    Small status label with Variants default, secondary, outline, destructive, success and warning.
  • Button
    Button with Variants (primary, secondary, outline, ghost, destructive, link), Sizes, an icon slot, loading and status; icon-only requires aria-label.
  • Card
    Card surface with CardHeader, CardTitle, CardDescription, CardContent and CardFooter; onPress makes the whole card pressable.
  • Checkbox
    Checkbox with an optional label, controlled or uncontrolled, an indeterminate (mixed) state and a haptic tick.
  • Chip
    Chip (plain, toggle or removable, with an optional icon) and ChipGroup (single or multiple choice on the SelectionGroup Primitive), with a haptic tick.
  • Container
    Wraps a Screen: safe-area edges, scrolling, keyboard avoidance, Token padding and a max content width.
  • Drawer
    Drawer panel content for Expo Router's drawer (expo-router/drawer): DrawerContent, DrawerHeader, DrawerSection, DrawerItem (label, icon, href, badge; active from the current route) and DrawerFooter.
  • Empty State
    What a Screen or list shows when there is nothing to show yet: an icon, a title, a description and an action.
  • FormField
    Lays out a control with its Label, description and error, and links them for screen readers ("Email, text field, Enter a valid email").
  • Icon
    Every glyph: a Lucide icon at a Token size in a Colour Role, decorative unless labelled.
  • Input
    Single-line text field with Sizes, a leading icon, error/success status, a focus ring and an automatic Show/Hide password toggle.
  • InputOTP
    One-time code field: one hidden input drives N cells; numeric keyboard, SMS/Keychain autofill, paste, onComplete, error/success status and secure.
  • Label
    A field's visible label in the label type Variant, with a required marker read as "required".
  • List
    Settings-style rows: ListSection, ListSectionHeader, ListSectionFooter and ListItem (title, description, icon, trailing, chevron, destructive, onPress); FlashList-safe.
  • Progress
    A horizontal progress bar: a value 0–100 that animates, or indeterminate; role progressbar with its value.
  • RadioGroup
    RadioGroup and RadioGroupItem: a single choice from a list on the SelectionGroup Primitive, vertical or horizontal, with a haptic tick.
  • SchemeSwitcher
    Theme switcher / dark mode toggle: System, Light and Dark as SegmentedTabs, or one icon button cycling through them. Calls setScheme, which persists the choice.
  • SearchField
    Search box with a search icon, an automatic clear (×) button, a loading spinner and onSubmit from the keyboard's Search key.
  • SegmentedTabs
    In-screen tabs (not navigation): segmented or underline Variants, an animated indicator, and only the selected tab's content rendered.
  • Separator
    A hairline divider, horizontal or vertical, decorative by default.
  • Skeleton
    A placeholder shape shown while content loads; pulses unless Reduce Motion is on, hidden from screen readers.
  • Slider
    Pick a number in a range by dragging or tapping (Gesture Handler + Reanimated), snapped to steps with a haptic tick; adjustable for screen readers.
  • Spinner
    An indeterminate loading indicator in a Colour Role, announced as busy; still under Reduce Motion.
  • Switch
    A themed on/off Switch, identical on iOS and Android, with an optional label, a thumb that slides (instant under Reduce Motion) and a haptic tick.
  • TabNavigation
    The bottom tab bar: the custom tabBar of an Expo Router JS Tabs Layout, with classic and floating Variants, per-tab icons and badges from Tabs.Screen options.
  • Text
    All text: the type ramp as Variants, text Colour Roles, alignment and the font-scaling switch.
  • Textarea
    Multi-line text field that grows from minRows to maxRows, with a character counter when maxLength is set.
  • Toast
    Sonner-style Toasts: a <Toaster /> in the root Layout and toast(), toast.success/error/info/warning/dismiss. At most 3 stacked, 4s auto-dismiss, swipe away, announced, above native modals.
  • Profile header + grouped sections
    Drawer panel with a profile header (avatar, name, email, plan Badge), labelled sections (Main, Workspace, Support) and Log out pinned in the footer.
  • SaaS workspace
    Drawer panel for a SaaS workspace: logo and name with a close button, an Accent Colour onboarding item with a 5 Steps Badge, main items, a bottom group (Notifications with a count, Settings, Docs, Help) and a footer with a team switcher and the user's avatar.
  • Cover header
    Drawer panel with a dark cover (large avatar, name, View profile, a bell with a count, an overflow button and a round + action), icon items active in Accent Colour text, a divider and text-only secondary items.
  • Classic centred
    A centred sign-in Screen: logo, email and password, Forgot password, an "or" divider, then Apple and Google, and a Sign up link.
  • Social-first hero
    A social-first sign-in Screen: a dark brand hero (logo, headline, subtitle, centred) above a panel where Apple and Google lead and "Continue with email" reveals the email form.
  • Email-first, two steps
    An email-first sign-in Screen in two steps: the email, then the password, with "Email me a code instead" switching to a 6-digit code (InputOTP). Lucide chevron-left Back buttons.
  • Classic single form
    A classic sign-up Screen on one scroll: Apple and Google on top, "or", then name, email and password, a terms Checkbox with Terms and Privacy Policy links, Create account, and a Sign in link.
  • Step-by-step wizard
    A sign-up wizard, one question per step under a Progress bar: email, name, password, then a 6-digit code (InputOTP). Lucide chevron-left Back buttons; the Continue button rides above the keyboard.
  • Social-first + verify
    A brand-led, passwordless sign-up Screen: the logo and headline centred above Apple and Google; "Sign up with email" reveals the email field, and Send code moves on to a 6-digit code (InputOTP).
  • announce
    Reliable screen-reader announcements on iOS and Android (Button and Input status, Toast, error, success and warning Alerts).
  • FocusChain
    Automatic Next/Done between fields in on-screen order; Done submits the form.
  • FormField context
    Links a field to its label, description and error for screen readers; passes status, disabled and required down.
  • Keyboard
    KeyboardProvider setup, a scroll view that keeps the focused field above the keyboard, and a footer that keeps a form's main button above it.
  • Portal
    Renders content above every Screen (above native modals on iOS), layered and safe-area aware.
  • Pressable
    Pressed look from a Style Slot, tap area extended to 48, no handlers while disabled or loading, optional haptics, role and aria-* states.
  • SelectionGroup
    Single or multiple choice within a group, with radio, tab or checkbox roles and selected states.
  • useControllableState
    One implementation of controlled and uncontrolled values for form controls.
  • useMotion
    Enter/exit animations and timing/spring configs from the motion Tokens; instant when Reduce Motion is on.
  • Theme
    Tokens, Scale, Colour Roles, ThemeProvider, createStyles and the navigation theme.

SelectionGroup

Single or multiple choice within a group, with radio, tab or checkbox roles and selected states.

View as Markdown
Primitiveforms
Style

Installation

$npx nativecn-cli@latest add selection-group

Also installs: use-controllable-state.

Props

SelectionGroup

PropType and description
type"single" (default) | "multiple"
valuestring (single) | string[] (multiple): controlled
defaultValuestring | string[]: uncontrolled
onValueChange(value) => void
itemRole"radio" | "tab" | "checkbox" (default radio for single, checkbox for multiple)
disabledboolean: disables every item
refRef<View>

useSelectionItem

PropType and description
useSelectionItem({ value, disabled? }){ selected, disabled, select, itemProps }: spread itemProps onto the item's Pressable (role, aria-checked/selected, tabIndex, onPress)

useSelectionGroup

PropType and description
useSelectionGroup()the group's context: selected values, items, select, move (single only: arrow keys, swipes) and the roving tab stop

Accessibility

  • The behaviour under RadioGroup, SegmentedTabs and ChipGroup: one implementation of single and multiple choice, roles and selected states, controlled or uncontrolled.

Notes

Build a custom group by rendering SelectionGroup and calling useSelectionItem in each item.

Source

The Vega Style's code, exactly as add copies it into your app.

{components}/primitives/selection-group.tsx

import {
  createContext,
  use,
  useCallback,
  useEffect,
  useMemo,
  useState,
  type ReactNode,
  type Ref,
} from "react";
import { View, type Role, type ViewProps } from "react-native";

import { useControllableState } from "@/hooks/use-controllable-state";

/**
 * How the group and its items are announced:
 * - `radio`: `radiogroup` / `radio` with `aria-checked` (RadioGroup, single ChipGroup).
 * - `tab`: `tablist` / `tab` with `aria-selected` (SegmentedTabs, TabNavigation).
 * - `checkbox`: `group` / `checkbox` with `aria-checked` (multiple ChipGroup).
 */
export type SelectionItemRole = "radio" | "tab" | "checkbox";

const groupRoles: Record<SelectionItemRole, Role> = {
  radio: "radiogroup",
  tab: "tablist",
  checkbox: "group",
};

type SelectionGroupBaseProps = Omit<ViewProps, "role"> & {
  /** How the group and its items are announced. Default `radio` (single) or `checkbox` (multiple). */
  itemRole?: SelectionItemRole;
  /** Disables every item in the group. */
  disabled?: boolean;
  ref?: Ref<View>;
  children?: ReactNode;
};

export type SelectionGroupSingleProps = SelectionGroupBaseProps & {
  type?: "single";
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
};

export type SelectionGroupMultipleProps = SelectionGroupBaseProps & {
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
};

export type SelectionGroupProps = SelectionGroupSingleProps | SelectionGroupMultipleProps;

type RegisteredItem = { value: string; disabled: boolean };
type Registration = RegisteredItem & { key: object };

export type SelectionGroupContextValue = {
  type: "single" | "multiple";
  itemRole: SelectionItemRole;
  disabled: boolean;
  /** Selected values, in selection order (at most one when `type` is `single`). */
  selected: readonly string[];
  /** Registered items in registration (mount) order. */
  items: readonly RegisteredItem[];
  isSelected: (value: string) => boolean;
  /** Single: select `value`. Multiple: toggle `value`. Ignored for disabled items. */
  select: (value: string) => void;
  /**
   * Single only: select the enabled item `delta` steps from the current one, wrapping around
   * and skipping disabled items (arrow keys, swipes). Returns the newly selected value.
   */
  move: (delta: number) => string | undefined;
  /** The value that holds the group's single tab stop (roving tab index), if any. */
  tabStop: string | undefined;
  /** Adds or updates the item identified by `key` (any stable object). */
  register: (key: object, item: RegisteredItem) => void;
  unregister: (key: object) => void;
};

const SelectionGroupContext = createContext<SelectionGroupContextValue | null>(null);

/**
 * Behaviour-only Primitive for a group of selectable items: single choice (RadioGroup,
 * SegmentedTabs, TabNavigation, ChipGroup `single`) or multiple choice (ChipGroup `multiple`).
 * It renders a plain View carrying the group role and no visuals; items opt in with
 * `useSelectionItem`.
 */
export function SelectionGroup(props: SelectionGroupProps) {
  const {
    type = "single",
    value,
    defaultValue,
    onValueChange,
    itemRole = type === "multiple" ? "checkbox" : "radio",
    disabled = false,
    ref,
    children,
    ...rest
  } = props;

  const [selectedRaw, setSelected] = useControllableState<string | string[] | undefined>({
    value,
    defaultValue,
    onChange: onValueChange as ((next: string | string[] | undefined) => void) | undefined,
  });
  const selected = useMemo<readonly string[]>(
    () =>
      selectedRaw === undefined ? [] : Array.isArray(selectedRaw) ? selectedRaw : [selectedRaw],
    [selectedRaw],
  );

  const [items, setItems] = useState<readonly Registration[]>([]);

  // Items keep the position of their first registration, even when their value or disabled
  // state changes later.
  const register = useCallback((key: object, item: RegisteredItem) => {
    setItems((prev) => {
      const index = prev.findIndex((i) => i.key === key);
      if (__DEV__ && prev.some((i) => i.key !== key && i.value === item.value)) {
        console.warn(`SelectionGroup: two items share the value "${item.value}".`);
      }
      const current = prev[index];
      if (!current) return [...prev, { ...item, key }];
      if (current.value === item.value && current.disabled === item.disabled) return prev;
      const next = [...prev];
      next[index] = { ...item, key };
      return next;
    });
  }, []);

  const unregister = useCallback((key: object) => {
    setItems((prev) => prev.filter((i) => i.key !== key));
  }, []);

  const isItemDisabled = useCallback(
    (v: string) => disabled || items.some((i) => i.value === v && i.disabled),
    [disabled, items],
  );

  const select = useCallback(
    (v: string) => {
      if (isItemDisabled(v)) return;
      if (type === "multiple") {
        setSelected((prev) => {
          const list = Array.isArray(prev) ? prev : prev === undefined ? [] : [prev];
          return list.includes(v) ? list.filter((x) => x !== v) : [...list, v];
        });
      } else {
        setSelected(v);
      }
    },
    [isItemDisabled, setSelected, type],
  );

  const enabled = useMemo(
    () => (disabled ? [] : items.filter((i) => !i.disabled).map((i) => i.value)),
    [disabled, items],
  );

  const move = useCallback(
    (delta: number) => {
      if (type !== "single" || enabled.length === 0) return undefined;
      const n = enabled.length;
      const index = enabled.indexOf(selected[0] ?? "");
      const start = index === -1 ? (delta > 0 ? -1 : 0) : index;
      const next = enabled[(((start + delta) % n) + n) % n];
      setSelected(next);
      return next;
    },
    [enabled, selected, setSelected, type],
  );

  // Single choice: one tab stop, on the selected item or else the first enabled one.
  // Multiple choice: every enabled item is its own tab stop, so no single holder.
  const tabStop =
    type === "single" ? (enabled.find((v) => v === selected[0]) ?? enabled[0]) : undefined;

  const context = useMemo<SelectionGroupContextValue>(
    () => ({
      type,
      itemRole,
      disabled,
      selected,
      items,
      isSelected: (v) => selected.includes(v),
      select,
      move,
      tabStop,
      register,
      unregister,
    }),
    [type, itemRole, disabled, selected, items, select, move, tabStop, register, unregister],
  );

  return (
    <SelectionGroupContext value={context}>
      <View ref={ref} role={groupRoles[itemRole]} aria-disabled={disabled || undefined} {...rest}>
        {children}
      </View>
    </SelectionGroupContext>
  );
}

/** The nearest SelectionGroup's state, for Components that need more than one item's view. */
export function useSelectionGroup(): SelectionGroupContextValue {
  const context = use(SelectionGroupContext);
  if (!context) {
    throw new Error("useSelectionGroup() must be used inside <SelectionGroup>.");
  }
  return context;
}

export type UseSelectionItemParams = {
  value: string;
  disabled?: boolean;
};

/** Props to spread onto the item's pressable root (e.g. the Pressable Primitive). */
export type SelectionItemProps = {
  role: Role;
  "aria-checked"?: boolean;
  "aria-selected"?: boolean;
  "aria-disabled"?: boolean;
  disabled: boolean;
  tabIndex?: 0 | -1;
  onPress: () => void;
};

export type SelectionItem = {
  selected: boolean;
  disabled: boolean;
  /** Single: select this item. Multiple: toggle it. No-op while disabled. */
  select: () => void;
  itemProps: SelectionItemProps;
};

/**
 * Registers an item with the nearest SelectionGroup and returns its selected state plus the
 * role, state and press handler to spread onto its root.
 */
export function useSelectionItem({
  value,
  disabled: ownDisabled = false,
}: UseSelectionItemParams): SelectionItem {
  const group = use(SelectionGroupContext);
  if (!group) {
    throw new Error("useSelectionItem() must be used inside <SelectionGroup>.");
  }
  const { register, unregister, itemRole, isSelected, select: selectValue, tabStop, type } = group;

  const [key] = useState(() => ({}));
  useEffect(() => () => unregister(key), [unregister, key]);
  useEffect(
    () => register(key, { value, disabled: ownDisabled }),
    [register, key, value, ownDisabled],
  );

  const selected = isSelected(value);
  const disabled = group.disabled || ownDisabled;
  const select = useCallback(() => {
    if (!disabled) selectValue(value);
  }, [disabled, selectValue, value]);

  const state = itemRole === "tab" ? { "aria-selected": selected } : { "aria-checked": selected };

  return {
    selected,
    disabled,
    select,
    itemProps: {
      role: itemRole,
      ...state,
      "aria-disabled": disabled || undefined,
      disabled,
      tabIndex: type === "single" ? (tabStop === value ? 0 : -1) : undefined,
      onPress: select,
    },
  };
}