npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gnome-ui/react-native

v1.14.0

Published

React Native components following the GNOME Human Interface Guidelines, targeting iOS/Android/GNOME-mobile shells

Readme

@gnome-ui/react-native

React Native component library following the GNOME Human Interface Guidelines, targeting iOS/Android/GNOME-mobile shells.

npm CI License: MIT

Status: theme tokens, GnomeProvider, Tier 1 Base (Button, Text, Link, TextField, Switch, Checkbox, RadioButton), Tier 2 Layout & Containers (Separator, Card, BoxedList, ActionRow, HeaderBar), and Tier 3 Navigation (Tabs, ViewSwitcher, Sidebar, SearchBar, PathBar) fully ported. Tier 4 Feedback: Spinner, ProgressBar, Skeleton, Toast/Toaster, Banner, Dialog, Tooltip, and AnimatedIcon (which brought a new Icon component along with it, as its own public component) and StatusPage shipped — Tier 4 complete. Tier 5 Advanced Controls fully ported: Dropdown, Slider, SpinButton, Avatar, Badge, and Popover. Beyond Tier 5, BottomSheet (Tier 14) and Overlay/LevelBar/Expander/Divider/Highlight/FileTypeIcon/ SegmentedBar/AvatarGroup/AvatarRotator/CoachMark/CoachMarkTour (Tier 20), Chip (Tier 7), IconButton/Drawer (Tier 8/Tier 20), and Clamp (Tier 6), Box (Tier 20), WrapBox/ToggleGroup (Tier 7), and InlineViewSwitcher (Tier 8), PreferencesGroup (Tier 13), and EntryRow/PasswordEntryRow/ComboRow/SpinRow (Tier 12), SplitButton (Tier 8), ColorPicker (Tier 20), Bin (Tier 15), Blockquote (Tier 20), ButtonRow (Tier 8), Callout (Tier 20), CheckRow (Tier 12), ExpanderRow (Tier 8), FieldGroup (Tier 20), and MultiSelectDropdown (Tier 20) also shipped, along with FilterableMultiSelectDropdown — an original @gnome-ui/react-only component (not a GNOME HIG port) built once its prerequisite MultiSelectDropdown landed — and PasswordField/ RangeSlider/StatusBadge/WidgetManager (all Tier 20) — WidgetManager was previously deferred as low-priority, built once named directly since every piece it composes had already shipped. BottomTabBar also shipped — a React Native-only original with no @gnome-ui/react source at all (desktop apps don't have a bottom tab bar pattern to mirror), built on explicit request for the iOS/Android fixed bottom-navigation shape. useBreakpoint and BreakpointBin (both Tier 6) also shipped — the package's first adaptive-layout primitives, built on useWindowDimensions and onLayout respectively since there's no CSS media query/container query to lean on. ButtonContent (Tier 15) also shipped — an icon+label layout helper mostly redundant with Button's own leadingIcon/ trailingIcon, kept for composing the same spacing outside Button itself. TagInput (Tier 20) also shipped — a WrapBox + Chip composition, unblocked once both had shipped. Component ports from @gnome-ui/react continue tier by tier — see this package's own ROADMAP.md for full per-tier status against all 130 @gnome-ui/react components, and the main ROADMAP.md Priority 3 for the framework expansion this package belongs to.

How it works

Bare React Native — no Expo SDK dependency, so the package works in both Expo-managed and bare RN apps. Components are rebuilt with native primitives (View, Text, Pressable, StyleSheet) rather than ported 1:1 from @gnome-ui/react's DOM-based JSX, but mirror its component API/props where the platforms overlap.

Theme tokens

scripts/generate-theme.mjs parses @gnome-ui/core's src/tokens.css with postcss and emits src/theme/tokens.generated.ts — four plain JS objects (lightTheme, darkTheme, highContrastTheme, highContrastDarkTheme), one per @media (prefers-color-scheme) / @media (prefers-contrast) combination in the source CSS. var() chains are resolved at generation time following the same cascade order as the CSS (base → dark → high contrast → high contrast + dark), so each object is a flat, fully-resolved map — no runtime CSS engine needed.

Units are converted to what RN styles expect: px/rem lengths become bare dp numbers (1rem = 16), ms durations become numbers, modern rgb(r g b / a) colors become rgba(r, g, b, a) strings, cubic-bezier() becomes a 4-number array, and a font stack like "Adwaita Sans", cantarell, … is reduced to just "Adwaita Sans" (RN's fontFamily takes one native family name — the app still has to load @gnome-ui/core's .ttf files).

oklch() (unsupported by RN's color parser), box-shadow strings, and the two clamp()-based sidebar-width tokens aren't auto-converted — they're still available, unconverted, in the matching *RawTokens export (e.g. lightRawTokens['--gnome-shadow-md']) so nothing is silently lost.

Pick a variant at runtime with resolveGnomeTheme:

import { resolveGnomeTheme } from '@gnome-ui/react-native';
import { useColorScheme } from 'react-native';

const colorScheme = useColorScheme() ?? 'light';
const theme = resolveGnomeTheme({ colorScheme, contrast: 'normal' });

theme.accentColor; // '#3584e4'
theme.space2; // 12

The generated file is committed, but always regenerate it after changing @gnome-ui/core's tokens — npm run theme:generate, or just run build / typecheck / test, which each regenerate it first.

GnomeProvider

RN has no CSS cascade, so components can't read a custom-property-style theme the way @gnome-ui/react's components do — they need the resolved theme object handed to them directly. GnomeProvider computes it once and exposes it (plus locale, direction, and formatting defaults) via context:

import { GnomeProvider, useGnomeTheme } from '@gnome-ui/react-native';
import { Text, View } from 'react-native';

function App() {
  return (
    <GnomeProvider accentColor="green">
      <Screen />
    </GnomeProvider>
  );
}

function Screen() {
  const theme = useGnomeTheme();

  return (
    <View style={{ backgroundColor: theme.windowBgColor, padding: theme.space4 }}>
      <Text style={{ color: theme.windowFgColor, fontSize: theme.fontSizeBody }}>Hello</Text>
    </View>
  );
}

colorScheme and contrast both default to "system": color scheme follows useColorScheme()/Appearance, and contrast follows the OS accessibility setting where one exists — Android's "High text contrast", iOS's "Increase Contrast" — falling back to "normal" elsewhere (e.g. web). Pass "light"/"dark" or "normal"/"more" to override either explicitly.

accentColor accepts a named Adwaita palette color ("green", "red", …) — resolved to the matching shade for the active color scheme, same as @gnome-ui/react — or any RN color string. It's threaded through theme.accentColor/theme.accentBgColor (and theme.focusRingColor outside high contrast, which keeps its own fixed value there for maximum contrast, matching tokens.css).

Other hooks: useLocale, useDir, useNumberFormatter, useDateTimeFormatter, useColorScheme/useResolvedColorScheme, useContrast/useResolvedContrast, useAccentColor — each reads one slice of the same context, mirroring @gnome-ui/react's GnomeProvider hook set.

Unlike the web provider, dir is exposed for consumers to branch on but never calls I18nManager.forceRTL() — RN's layout direction is a single global flag that needs an app reload and is set once at bootstrap, not per provider tree.

useReducedMotion() is the one hook in this set not scoped to GnomeProvider's context — it reads the OS "Reduce Motion" accessibility setting (AccessibilityInfo.isReduceMotionEnabled/reduceMotionChanged, supported on both iOS and Android) directly and works without a provider at all. Unlike contrast/colorScheme, the web GnomeProvider has no corresponding override prop for this — prefers-reduced-motion is a pure CSS media query there, always OS-driven — so there's nothing to mirror on the context side. Any component with a continuously looping Animated value (e.g. Spinner) reads it to slow down or skip that animation.

Components

Button

import { Button } from '@gnome-ui/react-native';

<Button variant="suggested" onPress={() => save()}>
  Save
</Button>;

Mirrors @gnome-ui/react's Button props (variant, size, shape, osd, leadingIcon/trailingIcon), rebuilt on Pressable — hover/:active CSS states become the pressed render-prop, and filter: brightness() (not available in RN) becomes a 0.85 opacity dip on press for the solid suggested/destructive variants. leadingIcon/trailingIcon render as-is: RN has no currentColor equivalent, so size and color icons yourself, matching the resolved label color (theme.accentFgColor, theme.destructiveFgColor, theme.windowFgColor, …) if you want them to match.

SplitButton

import { SplitButton, Button } from '@gnome-ui/react-native';

<SplitButton
  label="Save"
  onPress={() => save()}
  dropdownContent={
    <Button variant="flat" size="sm" onPress={() => saveAs()}>
      Save as Template
    </Button>
  }
/>;

Primary action button with an attached dropdown arrow, mirroring AdwSplitButton and @gnome-ui/react's own SplitButton. Pressing the label half fires onPress; pressing the arrow half opens a floating panel with dropdownContent (menus, options, etc.). Supports the same default/suggested/destructive variants as Button.

The label half is a real Button — its resting/pressed/disabled colors come for free. The arrow half is a hand-rolled Pressable, not a second Button: Popover (which supplies the floating panel) clones a prop-level accessibilityState onto its trigger, and RN merges a spread prop object outright rather than key-by-key — nesting a full Button there would silently clobber Button's own internal accessibilityState={{ disabled }}. The hand-rolled Pressable owns accessibilityState={{ disabled }} itself instead, letting Popover's clone merge in expanded alongside it. Its resting colors are derived from a small local color formula mirroring Button's own (unexported) one, so the two halves render pixel-identical colors; they read as one connected control via zeroed shared inner corner radii plus a 1px separator, the RN equivalent of the web CSS's split border-radius and .separator span.

Text

import { Text } from '@gnome-ui/react-native';

<Text variant="title-1">Settings</Text>
<Text variant="caption" color="dim">Last synced 5 minutes ago</Text>;

All 12 Adwaita text styles — large-title, title-1title-4, heading, body, document, caption, caption-heading, monospace, numeric — and the same 7 semantic colors as @gnome-ui/react (default, dim, accent, destructive, success, warning, error).

| Variant | Role | Use case | |---------|------|----------| | large-title | header | Display heading with lots of whitespace | | title-1 | header | Primary screen title | | title-2 | header | Section title | | title-3 | header | Sub-section title | | title-4 | header | Minor heading | | heading | header | UI labels, boxed list headers | | body | — | Default UI text, descriptions | | document | — | Reading content (chat, articles) | | caption | — | Sub-text, metadata | | caption-heading | — | Small group labels (uppercase) | | monospace | — | Code, logs, shell commands | | numeric | — | Aligned numbers, counters |

RN has no element to choose, so @gnome-ui/react's as prop is replaced by accessibilityRole: the six heading variants default to "header" — the native equivalent of <h1><h4>, and what the VoiceOver/TalkBack heading rotor reads — and passing accessibilityRole explicitly overrides that.

Three CSS-only typography features are resolved at render time instead: relative line-height ratios and em letter-spacing become absolute dp against each variant's own font size (RN accepts nothing else), and color="dim" stays an opacity rather than a flat gray, so it keeps working over any background — exactly what .color-dim does on the web.

Everything else is plain RN Text: numberOfLines, selectable, onPress, adjustsFontSizeToFit and the rest of TextProps pass straight through, style merges over the variant style, and ref reaches the underlying host Text.

Link

import { Link } from '@gnome-ui/react-native';

<Link href="https://gnome.org" external>
  GNOME
</Link>;

href is opened via Linking.openURL when pressed. Pass a custom onPress to hand it to a router instead (e.g. navigation.navigate for an internal link) — that fully replaces the default Linking.openURL call rather than running alongside it.

RN's Pressable has no :hover, so the underline the web Link reveals on hover instead reveals on press — the closest native equivalent — alongside the same 0.7 press-opacity dip as @gnome-ui/react's :active state.

external appends a trailing ↗ indicator and sets an "Opens in browser" accessibility hint on the pressable; the indicator itself is hidden from accessibility (accessibilityElementsHidden) since the hint already announces the same thing, mirroring the web version's aria-label on its icon span. Unlike the web Link, RN has no tab concept, so external is purely presentational — href always opens the same way regardless.

LinkedGroup

import { Button, LinkedGroup } from '@gnome-ui/react-native';

<LinkedGroup>
  <Button>Cut</Button>
  <Button>Copy</Button>
  <Button>Paste</Button>
</LinkedGroup>

Renders children as a single visually-connected unit with no gap and merged borders — the canonical GNOME pattern for button groups and segmented inputs. Mirrors @gnome-ui/react's LinkedGroup, itself mirroring the libadwaita .linked style class.

The web version reaches every child's border-radius via a CSS > * universal child selector — RN has no equivalent way for a parent View to reach into an arbitrary child's own internally-computed styles. Reimagined as the same cloneElement-onto-children technique Popover/Tooltip already use on their own trigger: each child gets a computed corner-radius/negative-margin override merged onto whatever style it already has, generalizing the "zero the shared inner corners, keep theme.radiusMd on the outer ones, overlap by 1 dp to collapse the shared border" recipe SplitButton already proved for its own fixed two-piece connected border. This only works because every component in this package already merges a passed-in style prop last — the same assumption Popover's own trigger-cloning already depends on, so any custom child passed to LinkedGroup needs to follow that same convention.

The web CSS also raises a hovered/focused child's z-index so its own border isn't visually covered by the next sibling's overlapping edge — dropped here: RN is touch-first (no :hover), and no component in this package currently renders an escaping focus ring that overlap could clip.

TextField

import { TextField } from '@gnome-ui/react-native';

<TextField
  label="Username"
  helperText="Enter your username"
  value={username}
  onChangeText={setUsername}
/>;

<TextField label="Email" error="This field is required" />;

RN has no <label htmlFor>/aria-describedby pairing, so label doubles as accessibilityLabel and error/helperText doubles as accessibilityHint on the underlying TextInput — announced together the same way aria-describedby reads them on the web. error, when set, replaces helperText in both the rendered hint row and the accessibility hint, and colors the border and hint text with theme.errorColor.

There's no :focus-visible distinction on RN, so the accent border on focus is plain onFocus/onBlur state rather than a keyboard-only ring; there's also no outer box-shadow, so focus is a border-color change only, not a grown ring like the web version's. The web disabled prop is RN's own editable={false} — mirrored as a dimmed wrapper (label, input, and hint together), matching @gnome-ui/react's .disabled wrapper class.

style targets the wrapping View; inputStyle targets the TextInput itself. Everything else (value, onChangeText, placeholder, keyboardType, secureTextEntry, …) is plain RN TextInputProps, and ref reaches the underlying TextInput.

Switch

import { useState } from 'react';
import { Switch } from '@gnome-ui/react-native';

function WifiRow() {
  const [enabled, setEnabled] = useState(true);

  return <Switch value={enabled} onValueChange={setEnabled} accessibilityLabel="Wi-Fi" />;
}

Rebuilt on Pressable/Animated.View rather than ported from @gnome-ui/react's <input type="checkbox" role="switch">: RN has no checkbox primitive to skin, and the platform-supplied Switch can't be made to match Adwaita, so the track and thumb are drawn by hand. value/onValueChange (not checked/onChange) mirror RN's own Switch API instead — the ecosystem convention this component overlaps with. It's fully controlled: there's no defaultValue escape hatch, matching the platform's own Switch.

Track background and border color animate on every value change after the initial mount — matching the CSS transition on .switch — using theme.durationFast/theme.easingDefault; the thumb's fill color does not animate, since the source CSS only transitions the thumb's transform, not its background-color.

Two of Switch.module.css's colors are hardcoded per color scheme inside a component-level @media (prefers-color-scheme: dark) block rather than driven by a semantic token. Unlike Button/Link/TextField, which each read a single token that already resolves correctly per theme, the unchecked track/thumb colors here branch explicitly on useResolvedColorScheme() to match.

Checkbox

import { useState } from 'react';
import { Checkbox } from '@gnome-ui/react-native';

function TermsRow() {
  const [accepted, setAccepted] = useState(false);

  return (
    <Checkbox value={accepted} onValueChange={setAccepted} accessibilityLabel="Accept terms" />
  );
}

// "Select all" with a mixed group:
<Checkbox value={allSelected} indeterminate={someSelected && !allSelected} onValueChange={selectAll} />;

Three states — unchecked, checked, and indeterminate (mixed) — same as @gnome-ui/react's. value/onValueChange mirror Switch's convention rather than the web version's checked/onChange.

RN has no indeterminate DOM property to set imperatively — the entire reason the web version needs a ref and an effect — so here it's just a render branch: indeterminate draws a short bar, otherwise a checkmark, both fading in on the same Animated.Value that drives the border/ background transition. The checkmark itself is a glyph rather than the web version's clip-path polygon, since this package has no SVG dependency to draw one exactly — the same Unicode-glyph fallback Link's external-link indicator already established for a small decorative mark.

The idle border color is another case (like Switch) where the source CSS hardcodes a palette swatch per color scheme rather than a token that already resolves per theme, so it branches on useResolvedColorScheme() — and on useResolvedContrast() for the high-contrast border color/width — to match.

RadioButton

import { useState } from 'react';
import { RadioButton } from '@gnome-ui/react-native';

function SizeOptions() {
  const [size, setSize] = useState<'sm' | 'md' | 'lg'>('md');

  return (
    <>
      {(['sm', 'md', 'lg'] as const).map((option) => (
        <RadioButton
          key={option}
          value={size === option}
          onSelect={() => setSize(option)}
          accessibilityLabel={option}
        />
      ))}
    </>
  );
}

Reuses Checkbox's exact border/background transition technique — same Animated.Value, same mount-skip guard so it never animates before the user touches it, same useResolvedColorScheme()/useResolvedContrast() branching for the idle border color — just circular, with a filled dot instead of a checkmark, and no indeterminate state.

The web version's <input type="radio" name="..."> groups mutually exclusive options natively via the shared name attribute; RN has no equivalent, so grouping is fully manual — render one RadioButton per option and drive value from shared selection state in the parent, same as any other controlled list of options. onSelect (not onValueChange) only fires when pressed while unselected, matching native radio semantics: pressing an already-selected radio is a no-op, so there's no boolean to report back.

Separator

import { Separator } from '@gnome-ui/react-native';

<Separator />;
<Separator orientation="vertical" style={{ height: 24 }} />;

Thin dividing line — the first component from Tier 2 (Layout & Containers). Color comes entirely from theme.cardShadeColor, which already resolves correctly per color scheme, so — unlike Switch/ Checkbox/RadioButton — there's no useResolvedColorScheme() branching needed here.

Rebuilt as a plain View rather than ported from @gnome-ui/react's <hr>/<div role="separator">: RN's AccessibilityRole union has no "separator" value, so — since a divider carries no information a screen reader user needs — it's excluded from the accessibility tree entirely with accessible={false}, the RN-idiomatic way to mark a purely decorative element.

Sidebar

import { Sidebar, SidebarItem, SidebarSection } from '@gnome-ui/react-native';

<Sidebar>
  <SidebarSection title="Mailboxes">
    <SidebarItem label="Inbox" icon={<InboxIcon />} active onPress={() => go('inbox')} />
    <SidebarItem label="Starred" icon={<StarIcon />} suffix={<Text variant="caption">3</Text>} />
  </SidebarSection>
  <SidebarSection title="Labels" collapsible>
    <SidebarItem label="Work" />
    <SidebarItem label="Archived" disabled />
  </SidebarSection>
</Sidebar>;

// Rail (icon-only) mode:
<Sidebar collapsed>…</Sidebar>;

// Controlled filtering — pair with your own search input:
<Sidebar filter={query}>…</Sidebar>;

Lateral navigation panel. Consecutive top-level children (typically SidebarSections) get a Separator inserted between them — the same divider-on-index-boundary technique BoxedList uses for its rows — standing in for the web version's .section + .section adjacent-sibling CSS rule, which RN has no equivalent of. A child that filter hides is excluded from that index count too, so a lone visible row never ends up sandwiched between two stray dividers.

SidebarSection is collapsible via its header Pressable or imperatively through a ref (expand/collapse/toggle) — the body stays mounted and toggles display: 'none' rather than unmounting, the same "stays mounted but hidden" approach TabPanel uses, instead of porting the web version's animated CSS-grid collapse. In rail (collapsed) mode every section header is hidden and every body is always shown.

Dropped relative to @gnome-ui/react's Sidebar/SidebarItem: searchable (would pull in a SearchBar, not yet ported to this package — use filter with your own input instead), mode/auto page-layout switch (depends on the web-only useBreakpoint hook), variant (tinted/blurred backgrounds — the blurred variant needs a native blur view this package doesn't depend on), tooltip (no Tooltip port yet, and nothing to trigger one from on a touch-first device), menuItems (context menu — no portal/positioning primitive exists in this package yet), and onDrop/acceptTypes (HTML5 drag-and-drop has no RN equivalent without a gesture-handler dependency this package doesn't have).

SearchBar

import { SearchBar } from '@gnome-ui/react-native';

<SearchBar
  open
  value={query}
  onChangeText={setQuery}
  onClear={() => setQuery('')}
  onClose={() => setOpen(false)}
/>;

// Filter chips below the bar:
<SearchBar open value={query} onChangeText={setQuery}>
  <Chip label="Apps" />
  <Chip label="Documents" />
</SearchBar>;

Collapsible search input. open={false} renders nothing at all rather than porting the web version's CSS height/opacity transition — no established animated-height pattern exists yet in this package (the same trade-off SidebarSection made for its collapsible body) — and mounting on open={true} auto-focuses the input, standing in for the web version's requestAnimationFrame-on-open focus effect.

onClose renders a trailing "Cancel" button rather than being wired to an Escape keypress: touch keyboards have no reliable Escape key, so a visible button is the RN-idiomatic stand-in. The clear (×) button appears whenever value is non-empty, mirroring the web version, and both icons are Unicode glyphs (🔍/×) rather than @gnome-ui/icons, matching every other no-SVG-dependency component in this package.

inline drops the header-bar background/border so the bar blends into any surface — a card, a plain content area, a custom container — instead of looking like it belongs to a HeaderBar.

Dropped relative to @gnome-ui/react's SearchBar: the suggestions / onSuggestionSelect / loadingSuggestions / renderSuggestion / suggestionsLabel autocomplete popover — it depends on a portal + viewport-anchored positioning primitive (createPortal + getBoundingClientRect) this package doesn't have yet, the same gap that dropped SidebarItem's menuItems context menu — and a Spinner component, not yet ported.

PathBar

import { PathBar } from '@gnome-ui/react-native';

<PathBar
  segments={[
    { label: 'Home', path: '/home' },
    { label: 'Documents', path: '/home/documents' },
    { label: 'Projects', path: '/home/documents/projects' },
  ]}
  onNavigate={(path, index) => go(path)}
/>;

Breadcrumb location bar. Segments are separated by a chevron; every segment except the last is a pressable button that calls onNavigate with its path and index, and the last segment renders as a static bold label — the current location.

Rebuilt with Pressable/View/Text rather than ported from @gnome-ui/react's <nav><ol><li>: RN's AccessibilityRole union has neither a "navigation" landmark nor a breadcrumb-list role (the same gap that dropped Sidebar's <nav> role), so those are dropped rather than faked — each interactive segment still gets its own accessibilityRole="button" and accessibilityLabel. The separator is a Unicode glyph instead of the web version's inline SVG chevron, matching this package's established no-SVG-dependency convention.

Spinner

import { Spinner } from '@gnome-ui/react-native';

<Spinner />;
<Spinner size="lg" label="Syncing your library…" />;

// Rendered alongside your own label instead of announcing its own:
<Spinner label="" />;

Indeterminate loading ring — the first component from Tier 4 (Feedback). size is "sm" | "md" | "lg" (16/24/36px). label defaults to "Loading…"; pass "" to silence it when a sibling label already describes the loading state (mirrors the web version's same convention).

Rebuilt on Animated.View rather than ported from @gnome-ui/react's pure-CSS @keyframes spin: the ring itself reuses the same per-side-border trick the CSS does (borderColor for the track, borderTopColor for the accent-colored "head", on a fully-rounded circle) — RN's View supports independent per-side border colors too, so that part translates directly. The rotation is an Animated.loopd Animated.timing driving a rotate transform with useNativeDriver: true. useReducedMotion() (see GnomeProvider above) mirrors the source CSS's own @media (prefers-reduced-motion: reduce) { animation-duration: 2s } — slowed to 2s, not stopped outright, matching the web behavior exactly rather than dropping the animation entirely.

RN's AccessibilityRole union has no "status" value (the web version's role="status"); "progressbar" is the closest match for an indeterminate loading indicator, with no accessibilityValue set — RN's equivalent of omitting aria-valuenow for an indeterminate progress bar.

ProgressBar

import { ProgressBar } from '@gnome-ui/react-native';

<ProgressBar value={0.6} accessibilityLabel="Download progress" />;
<ProgressBar variant="success" value={1} />;

// Indeterminate — unknown duration:
<ProgressBar accessibilityLabel="Loading" />;

Determinate and indeterminate progress bar. value (0–1) shows exact progress with an animated width transition on every change; omit it for an indeterminate 40%-wide bar that slides left to right on a loop. variant is "accent" (default) | "success" | "warning" | "error".

useReducedMotion() (see GnomeProvider above) is honored per the source CSS's own per-state behavior rather than one uniform rule: determinate width changes simply skip the transition, while the indeterminate pulse stops entirely and freezes as a static, full-width, 50%-opacity bar — exactly what the source @media (prefers-reduced-motion: reduce) block does. This differs from Spinner, whose reduced-motion behavior slows its animation instead of stopping it outright — each component mirrors its own source CSS rather than a single reduced-motion policy applied uniformly across the package.

role="progressbar" maps directly to RN's own accessibilityRole (no substitution needed, unlike Spinner's web role="status"). accessibilityValue carries min/max/now for the determinate case; the indeterminate case omits all three — RN's equivalent of the web version omitting aria-valuenow/aria-valuemin/aria-valuemax. The web version's aria-labelledby (an id-relationship prop) has no RN equivalent — RN has no DOM ids — so only aria-label (accessibilityLabel) is ported.

Skeleton

import { Skeleton } from '@gnome-ui/react-native';

<Skeleton />;
<Skeleton width={220} height={16} />;
<Skeleton variant="circle" size={48} />;
<Skeleton variant="text" lines={3} />;
<Skeleton animated={false} />;

Content-shaped loading placeholder — a pragmatic web-style extension for layouts that benefit from placeholder shape (GNOME HIG itself recommends Spinner/ProgressBar for loading states, but this is ported as-is from @gnome-ui/react for parity). variant is "rect" (default, width/ height) | "circle" (size diameter) | "text" (lines rows, the last one narrower).

The web version's shimmer is a linear-gradient swept across the shape via transform: translateX(); this package has no gradient dependency (no expo-linear-gradient/react-native-linear-gradient in its dependency tree, and adding one for a single component would be scope creep), so animated drives a plain opacity pulse instead — the same 1.4s round-trip cycle length as the web shimmer. This is a common RN-idiomatic substitute for a CSS shimmer effect (compare Tailwind's own animate-pulse utility, which uses the identical technique).

Unlike Spinner (slows) and ProgressBar (stops one state, slows the other), useReducedMotion() here fully disables the pulse and shows a static base color — mirroring the source CSS's own animation: none, which has no partial-motion in-between state to preserve. Each Tier 4 component's reduced-motion behavior follows its own source CSS rather than one policy applied uniformly across the package.

accessible={false} mirrors the web version's aria-hidden="true" — a loading placeholder carries no information a screen reader user needs, the same reasoning Separator already established for a purely decorative element.

Toast / Toaster

import { Toast, Toaster } from '@gnome-ui/react-native';

function App() {
  const [toasts, setToasts] = useState<{ id: number; message: string }[]>([]);

  return (
    <View style={{ flex: 1 }}>
      <YourAppContent />

      <Toaster>
        {toasts.map((t) => (
          <Toast
            key={t.id}
            title={t.message}
            dismissible
            onDismiss={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
          />
        ))}
      </Toaster>
    </View>
  );
}

Non-blocking temporary notification. Toast auto-dismisses after duration ms (default 3000, 0 disables it) and stays fully prop-driven — you own the list of active toasts and remove one from it in onDismiss, exactly like the web version. Toaster stacks them, positioned "bottom" (default) or "top".

RN has no document.body/portal target to render into the way the web version's createPortal does, so there's no container prop — mount Toaster yourself as the last child of your app's root-level View so it paints on top of everything else (see the example above). pointerEvents="box-none" on Toaster is the RN equivalent of the web version's pointer-events: none on the container: empty space around the stack doesn't intercept touches, but each Toast (a Pressable) still handles its own.

The timer-pause behavior is ported verbatim (setTimeout/Date.now() bookkeeping, no DOM API involved) — only the trigger changes: RN has no hover, so onPressIn/onPressOut (touch-down/touch-up) stand in for the web version's onMouseEnter/onMouseLeave, pausing the auto-dismiss timer while the user is actively touching the toast. There's no onFocus/onBlur-triggered pause either — the card itself isn't focusable in RN's touch-first model, only its action/dismiss buttons are, and RN has no "focus-within" primitive to detect that.

The entrance is an Animated.timing fading + sliding + scaling in, matching the web version's @keyframes toast-in; useReducedMotion() skips straight to the settled state. RN's AccessibilityRole union has no "status" value (the web version's role="status"); "alert" is the closest available role, paired with accessibilityLiveRegion="polite" (Android's live-region API) as the nearest match to aria-live="polite".

Banner

import { Banner } from '@gnome-ui/react-native';

<Banner variant="info">A new version is available.</Banner>;
<Banner variant="error" actionLabel="Retry" onAction={() => {}}>
  Sync failed
</Banner>;
<Banner variant="success" dismissible onDismiss={() => {}}>
  Changes saved successfully.
</Banner>;

Persistent message strip for the top of a view. variant is "info" (default) | "warning" | "error" | "success", each mapping to the matching theme.<variant>BgColor/<variant>FgColor token pair. Unlike Toast, it never auto-dismisses — it stays until the user acts or presses the optional dismiss button, so there's no duration prop at all.

Same accessibility substitution as Toast: RN's AccessibilityRole union has no "status" value (the web version's role="status"), so "alert" + accessibilityLiveRegion="polite" stands in for it. The banner itself is a plain View, not Pressable (only its action/dismiss buttons are interactive), so — per the BoxedList lesson that a bare View isn't an accessibility element by default — accessible is set explicitly alongside accessibilityRole.

The web version's per-variant :hover/:active background tint on the action/dismiss buttons (a light overlay on the darker info/error/success backgrounds, a dark one on the light warning background) collapses to a single Pressable-pressed-state overlay, the same simplification Toast and Card already made for their own press states.

Dialog

import { Dialog } from '@gnome-ui/react-native';

// Standard
<Dialog open={open} title="About Sync" onClose={() => setOpen(false)}>
  Files are synced automatically every 15 minutes.
</Dialog>;

// With buttons
<Dialog
  open={open}
  title="Discard changes?"
  onClose={() => setOpen(false)}
  buttons={[
    { label: 'Keep editing', onPress: () => setOpen(false) },
    { label: 'Discard', variant: 'destructive', onPress: () => setOpen(false) },
  ]}
>
  Your changes have not been saved.
</Dialog>;

// Alert — role="alertdialog" + responses/onResponse
<Dialog
  open={open}
  role="alertdialog"
  title="Delete file?"
  responses={[
    { id: 'cancel', label: 'Cancel' },
    { id: 'delete', label: 'Delete', variant: 'destructive' },
  ]}
  onResponse={(id) => setOpen(false)}
>
  This action cannot be undone.
</Dialog>;

Blocking modal dialog. Standard takes title + children + buttons[] with per-button onPress; Alert (role="alertdialog") takes responses[] + a single onResponse(id) instead — the same two-API shape as @gnome-ui/react's Dialog, since AlertDialog there is a mode of the same component rather than a separate one. AboutDialog (a distinct @gnome-ui/react component, not a Dialog variant) has no RN port yet.

Built on RN's own Modal (transparent, animationType="none" — the entrance is a custom Animated.timing) rather than the web version's DOM Portal + manual focus trap: Modal already floats above everything with no portal target needed, and already blocks interaction with the screen behind it, so there's no useBodyScrollLock port. Its onRequestClose fires on the Android hardware back button — the direct analog of the web version's document-level Escape listener (iOS has no back button, so this is Android-only, matching the platform's own convention). Focus trapping (Tab/Shift+Tab cycling between focusable elements) has no port at all — there's no keyboard Tab concept in RN's touch-first model, the same reasoning that already dropped TabBar's roving-tabindex arrow keys.

role is set via RN's newer, web-aligned role prop (not accessibilityRole) — its Role union has real "dialog"/"alertdialog" values, unlike the older AccessibilityRole enum Toast/Banner had to substitute "alert" into for the web's role="status". accessibilityViewIsModal (iOS-only) is the closest match to aria-modal="true", restricting VoiceOver to the dialog's subtree. The dialog card sets accessible explicitly (a bare View with role isn't an accessibility element by default — the same BoxedList lesson) — this shares the same open, unverified-on-a-real-device accessibility question already flagged for BoxedList/TabBar/ViewSwitcher's container-role pattern: accessible={true} on a container may collapse its subtree into one opaque VoiceOver stop, which for Dialog specifically would mean its footer buttons become unreachable via VoiceOver even though they're independently Pressable. Kept for getByRole testability and consistency with the established pattern, but this is the component where that tradeoff matters most — worth prioritizing for real-device screen reader verification before it's treated as settled.

Tooltip

import { Button, Tooltip } from '@gnome-ui/react-native';

<Tooltip label="Save file" placement="top">
  <Button accessibilityLabel="Save">Save</Button>
</Tooltip>;

Floating informational label. Positioned automatically and flips to the opposite side (then to whichever side actually fits) when the preferred placement has no room — the same algorithm as @gnome-ui/react's Tooltip, measured with measureInWindow() instead of getBoundingClientRect().

Trigger differs from the web version by necessity: the web Tooltip only shows on mouse hover / keyboard focus — touch has no hover state, so it explicitly never shows on touch. RN is touch-first, so the primary trigger here is long-press (delayLongPress={delay}, released via onPressOut) — the standard mobile "peek" idiom. onHoverIn/onHoverOut are also wired for hover-capable input (trackpad/mouse on iPad, or a pointer-driven RN target) and onFocus/onBlur for external-keyboard accessibility, both delayed the same way the web version delays hover.

Built on RN's own Modal (transparent, pointerEvents="box-none"), the same portal-substitute Dialog uses. role="tooltip" ports 1:1 — RN's Role union already has a "tooltip" value. aria-describedby has no RN equivalent, so the label is set as the trigger's accessibilityHint instead (unless the trigger already provides its own). The bubble's arrow reuses the same zero-size / transparent-border-on-three-sides triangle trick as the web CSS — RN Views support per-side border*Color too, the same technique Spinner's ring already relies on.

Not ported: repositioning on scroll/resize while visible (RN has no global scroll event, and a long-press is naturally cancelled by a scroll gesture starting). tooltipBgColor/tooltipFgColor aren't real @gnome-ui/core tokens (only CSS var fallbacks), so the RN port hardcodes the same literal light/dark values, the same workaround Spinner's track color established.

Icon

import { Search } from '@gnome-ui/icons';
import { Icon } from '@gnome-ui/react-native';

<Icon icon={Search} label="Search" size="lg" color="blue" />;

Renders an icon as an inline SVG via react-native-svg (a new peer dependency — this package's first). Accepts the same AnyIconDefinition union as @gnome-ui/react's Icon: a structured paths-based IconDefinition from @gnome-ui/icons, a simple-icons SimpleIcon, or a plain { path } object. color picks a named GNOME palette hue (theme.blue3, theme.red3, …) — RN has no currentColor to inherit from a parent the way the web version does, so omitting color resolves to the theme's default foreground color explicitly instead.

animated icons (Syncing, Recording, Downloading, Connecting) carry raw svg markup instead of paths — rendered here through react-native- svg's SvgXml. It parses the structural elements (<g>/<path>/ <circle>) but has no CSS engine, so the markup's embedded <style>/ @keyframes block is silently dropped and the shapes render at their authored rest position — which happens to be exactly the desired inert, static-frame behavior for a plain <Icon>, no special-casing needed. Wrap in <AnimatedIcon> to actually play the motion.

AnimatedIcon

import { Syncing } from '@gnome-ui/icons';
import { AnimatedIcon } from '@gnome-ui/react-native';

<AnimatedIcon icon={Syncing} playing={isSyncing} label="Syncing" />;

Plays the motion for a known animated icon (Syncing, Recording, Downloading, Connecting) — rendered through plain <Icon>, these show a static frame instead, same as @gnome-ui/react's AnimatedIcon.

Unlike the web version (which plays a CSS animation embedded in the icon's raw svg markup via a --gnome-icon-play-state custom property), RN has no CSS engine to interpret @keyframes at all. Each of the 4 known icons' motion is instead hand-built with Animated, matched by referential identity against @gnome-ui/icons' own exports (Syncing → full-turn rotation, Recording → opacity pulse, Downloading → a translate+opacity "drop" on the arrow over a static tray, Connecting → three signal dots pulsing in a staggered sweep) — an icon AnimatedIcon doesn't recognize (a future 5th animated icon, or a consumer-authored one) falls back to the static <Icon> frame rather than throwing. Regardless of playing, the animation is always paused when the OS reduced-motion setting is on.

Dropdown

import { Dropdown } from '@gnome-ui/react-native';

<Dropdown
  options={[
    { value: 'blue', label: 'Blue' },
    { value: 'green', label: 'Green', description: 'A calm accent' },
  ]}
  value={accentColor}
  onChange={setAccentColor}
  placeholder="Accent color"
/>;

Expandable option list following the Adwaita combo-row pattern, mirroring @gnome-ui/react's Dropdown.

Built on RN's own Modal (transparent) — the same portal-substitute Dialog/Tooltip already use — with a full-screen backdrop Pressable that closes the list on an outside tap (the RN analog of the web version's document-level "click outside" listener; unlike Tooltip's backdrop, this one isn't pointerEvents="box-none", since it's meant to catch that tap rather than pass it through). open flips synchronously on trigger press; the trigger's on-screen rect and the panel's own rendered height each resolve independently into state, combined by a separate effect into the final position and flip-up/flip-down direction — the same two-independent- async-measurements pattern Tooltip established.

Keyboard navigation (↑/↓ roving highlight, Home/End, type-ahead) has no port — RN's touch-first model has no keyboard focus to drive it, the same reasoning that already dropped TabBar's roving-tabindex arrow keys. Selection is by direct tap only. role="combobox" on the trigger ports 1:1; RN's Role union has no "listbox" value, so the panel uses role="list" instead — the same closest-available substitution BoxedList already established for a plain list container.

Slider

import { Slider } from '@gnome-ui/react-native';

<Slider
  value={volume}
  onChange={setVolume}
  accessibilityLabel="Volume"
  marks={[
    { value: 0, label: 'Min' },
    { value: 100, label: 'Max' },
  ]}
/>;

Draggable range control following the Adwaita GtkScale pattern, mirroring @gnome-ui/react's Slider.

Touch drag is handled with RN's own PanResponder (this package's first use of it) reading each touch event's locationX — the position relative to the track view itself, recalculated by RN on every touch/move — so no measureInWindow round-trip is needed at all, unlike Tooltip/Dropdown's trigger-rect measurement. min/max/step clamping and snapping is ported verbatim from the web version's pure-JS math.

The web version's keyboard interaction (← / → one step, Page Up/Down ten steps, Home/End to the bounds) has no RN equivalent — a touch-first device has no keyboard driving those keys. Rather than dropping value-adjustment accessibility entirely (the reasoning that dropped Dropdown's/TabBar's keyboard nav), accessibilityRole="adjustable" + onAccessibilityAction/accessibilityActions wires up the "increment"/ "decrement" actions VoiceOver's swipe-up/down and TalkBack's local-context menu generate for an adjustable element — the real native analog of keyboard stepping, one step per action. The bigger Page Up/Down and Home/End jumps have no equivalent screen-reader gesture on either platform, so only single-step adjustment is ported.

RN's transform only accepts pixel offsets, unlike the CSS % units the web version's left: X%; transform: translate(-50%, -50%) thumb/tick centering trick needs — so those are positioned with a plain pixel left computed from the track's onLayout-measured width instead. Mark labels use a different trick, since (unlike the thumb/ticks) their own rendered width isn't a known constant: a zero-width View with alignItems: 'center' at the mark's percentage left lets Yoga center the Text child around that point regardless of how wide the label renders, with no measurement needed.

SpinButton

import { SpinButton } from '@gnome-ui/react-native';

<SpinButton value={quantity} onChange={setQuantity} min={0} max={10} accessibilityLabel="Quantity" />;

Numeric −/+ stepper following the Adwaita GtkSpinButton pattern, mirroring @gnome-ui/react's SpinButton. The min/max/step/decimals/wrap/ format clamp-and-format math ports verbatim (pure JS, no DOM involved).

The primary interaction is tapping the visible −/+ buttons, same as a sighted mouse user on the web version. The web version's keyboard interaction (↑/↓ one step, Page Up/Down ten steps, Home/End to bounds) has no RN equivalent — a touch-first device has no keyboard to drive it, the same reasoning Slider already applied. Rather than dropping value adjustment accessibility entirely, single-step increment/decrement reuses Slider's exact accessibilityRole="adjustable" + onAccessibilityAction/accessibilityActions recipe (VoiceOver's swipe-up/down, TalkBack's local-context menu) — the bigger Page Up/Down and Home/End jumps have no equivalent screen-reader gesture on either platform, so those alone are dropped, same as Slider. The visible −/+ buttons and value text are hidden from the accessibility tree (accessibilityElementsHidden/importantForAccessibility="no", mirroring the web version's aria-hidden/tabIndex={-1} on both <button>s and the value <span>) so a screen reader user gets one adjustable stop, not three.

Avatar

import { Avatar } from '@gnome-ui/react-native';

<Avatar name="Grace Hopper" size="lg" />
<Avatar src="https://example.com/alice.jpg" alt="Alice's profile photo" />;

Circular avatar with image or initials fallback, mirroring @gnome-ui/react's Avatar. The color-hash and initials-extraction math ports verbatim (pure JS, no DOM involved).

The outer container carries role="img" + accessibilityLabel — RN's newer web-aligned Role union has an "img" value, a direct 1:1 port of the web version's role="img", no substitution needed (same as ProgressBar's role="progressbar"). The image/initials underneath are hidden from the accessibility tree, mirroring the web version's aria-hidden on both, so a screen reader gets one stop, not two — same reasoning as SpinButton's hidden −/+ buttons.

The web CSS's box-shadow: inset 0 0 0 1px … ring becomes a real 1px borderWidth/borderColor here (RN has no inset shadow) — the same substitution Slider's thumb border already used for a ring effect.

Badge

import { Avatar, Badge } from '@gnome-ui/react-native';

<Badge variant="error" anchor={<Avatar name="Alice Bob" />}>3</Badge>
<Badge dot variant="success" />;

Counter or status indicator, optionally overlaid on another element, mirroring @gnome-ui/react's Badge. children renders as a themed Text label when it's a string or number (the common case — counts and short text); any other node renders as-is, the same convention Button's children already established.

The web CSS's box-shadow: 0 0 0 2px var(--gnome-window-bg-color) ring (always present, separating the badge from whatever's behind it) has no RN equivalent that avoids affecting layout — RN's border* shrinks the content box instead of drawing outside it. Reproduced instead with an outer wrapping View (2px padding, theme.windowBgColor background, pill radius) around the actual colored badge, so the ring appears to spread outward exactly like the web version's non-blurred shadow, without eating into the badge's own text padding.

Popover

import { Button, Popover, Text } from '@gnome-ui/react-native';

<Popover content={<Text>Rich content here</Text>}>
  <Button>Open</Button>
</Popover>;

Floating panel anchored to a trigger element, following the Adwaita GtkPopover pattern, mirroring @gnome-ui/react's Popover. Unlike Tooltip, it can hold rich interactive content (buttons, links, forms).

Reuses this package's own established pieces rather than re-deriving them: Tooltip's cloneElement-onto-an-arbitrary-trigger architecture and 4-placement fallback-cascade positioning (no arrow-offset-shift-when-clamped — same simplification Tooltip already accepted), and Dropdown's toggle-on-press + full-screen backdrop Pressable that closes on an outside tap plus reduced-motion fade-in.

Deliberate divergence from Dropdown's backdrop structure: Dropdown nests its panel directly inside the backdrop Pressable and gets away with it because almost every pixel of its panel is itself a Pressable option row, which claims the touch responder before it can bubble to the backdrop. A popover's content is arbitrary — likely to have inert padding/whitespace with no Pressable of its own — so nesting the same way would let a tap on inert panel space fall through to the backdrop and close the popover, unlike the web version's .contains() check (which never closes on any tap inside the panel). Fixed with onStartShouldSetResponder={() => true} on the panel itself: it claims the touch responder for any touch RN's negotiation hasn't already given to a deeper Pressable inside content, without making the panel itself behave like a button.

BackHandler's hardwareBackPress (wired the same way Dialog already does) is the Android analog of the web version's document-level Escape listener. Focus-trapping and focus-restore-on-close have no port — no DOM document.activeElement/querySelector equivalent exists in RN, the same gap already present in Dialog/Tooltip/Dropdown.

The web version's rotated-square-with-matching-background arrow is replaced with Tooltip's simpler transparent-border-triangle technique — the same visual affordance, a much simpler RN-native primitive.

BottomSheet

import { BottomSheet, Button } from '@gnome-ui/react-native';

<Button onPress={() => setOpen(true)}>Open</Button>
<BottomSheet open={open} title="Options" onClose={() => setOpen(false)}>
  <Text>Rich content here</Text>
</BottomSheet>;

Slide-up panel that overlays content from the bottom edge, mirroring AdwBottomSheet (libadwaita 1.6+) and @gnome-ui/react's BottomSheet. Reuses Dialog's backdrop-opacity-on-an-AnimatedPressable + no-op-Pressable-around-the-card recipe, and BackHandler's hardwareBackPress as the Android analog of the web version's Escape listener.

Real drag-to-dismiss, not a fixed-panel simplification: PanResponder (the same core API Slider already proved handles a threshold gesture) drives a single Animated.Value shared with the entrance/exit animation — dragging the handle bar past 150 px (same constant as the web version) requests a close; releasing short of that springs back to 0. A real slide-up needs the sheet's own height first (RN's transform has no percentage-of-self units, the same Slider/Avatar pitfall) — the sheet renders once off-screen, measured via onLayout, before animating in.

A real, timed exit animation, unlike Dialog: Dialog's web source has no exit keyframes at all, but BottomSheet's does — ported with a local visible state that lags one animation behind the open prop, flipping to false only in the exit Animated.timing's own completion callback.

The web version's backdrop-filter: blur(4px) has no port (no native blur view dependency, same reasoning that dropped Sidebar's blurred variant), and useBodyScrollLock needs no RN equivalent (Modal already blocks all background interaction). children, when a plain string, is wrapped in Text before rendering — RN throws if a raw string is a View's child, unlike the web version's plain <div>{children}</div>.

Overlay

import { Overlay, Button } from '@gnome-ui/react-native';

<Button onPress={() => setOpen(true)}>Open</Button>
<Overlay open={open} onDismiss={() => setOpen(false)}>
  <YourOwnCard />
</Overlay>;

Standalone backdrop/scrim layer with a fade transition and press-to-dismiss — the shared building block behind Dialog, Dropdown, Popover, and BottomSheet's own backdrops, extracted here for building custom overlay UI, mirroring @gnome-ui/react's Overlay.

Deliberately minimal, same as the web version: no focus trap, no BackHandler/Escape handling, no role — use Dialog/Popover/ BottomSheet directly when you need those. Reuses Dialog's exact backdrop recipe (AnimatedPressable + a no-op Pressable wrapping children, so a tap on your own content never bubbles to the backdrop and dismisses it) and BottomSheet's real, timed exit animation technique — a local visible state that lags the open prop by one Animated.timing, flipping to false only in that animation's own completion callback.

Not retrofitted into Dialog/Dropdown/Popover/BottomSheet — each already ships and is fully tested with its own inline copy of this same backdrop pattern (with small per-component differences: Popover claims the touch responder differently than the no-op-Pressable wrapper the others use). Extracting Overlay as a new standalone primitive was the scoped ask; retrofitting four already-shipped components to share it is a separate, riskier refactor this turn didn't take on.

LevelBar

import { LevelBar } from '@gnome-ui/react-native';

<LevelBar value={0.15} low={0.25} high={0.75} accessibilityLabel="Battery" />
<LevelBar value={0.6} discrete numBlocks={5} accessibilityLabel="Signal strength" />;

Discrete level indicator with color-coded low/high offset zones, mirroring GtkLevelBar and @gnome-ui/react's LevelBar. Use for a gauge/ measurement display (disk usage, battery, signal strength) — not for task progress (ProgressBar) or a proportional category breakdown (SegmentedBar).

The continuous fill reuses ProgressBar's exact animation technique rather than animating width directly: a fixed width: '100%' fill with transformOrigin: 'left' and an animated transform: [{ scaleX }], so the whole thing runs on useNativeDriver: true — a JS-driven width animation schedules its next frame via a plain setTimeout that routinely fires after a test's render() returns but before unmount, producing a spurious "update not wrapped in act()" warning, the same reasoning ProgressBar's own docstring documents. useReducedMotion() mirrors ProgressBar's determinate behavior (duration drops to 0, an immediate jump).

Discrete mode's per-block color transition has no port — a value change is a plain, unanimated color swap per block, a decorative nicety rather than a behavior gap. role="meter" ports 1:1 from RN's newer web-aligned Role union (unlike AccessibilityRole, which has no "meter" value at all).

Expander

import { Expander } from '@gnome-ui/react-native';

<Expander label="Show advanced options">
  <TextField label="Custom endpoint" />
</Expander>

Standalone disclosure triangle + collapsible content, mirroring GtkExpander and @gnome-ui/react's Expander. A bare, unstyled counterpart to ExpanderRow; use it outside a settings-row context (e.g. "Show advanced options" in a form, or "Show details" under an error message).

The web version clips the panel with a CSS grid-height animation and rides the content's padding-top on a second, separate transition, so a collapsed expander doesn't reserve blank space for hidden padding. RN has no CSS grid to lean on, so the panel is a single Animated.View whose numeric height is driven directly (useNativeDriver: false, the same accepted trade-off Checkbox/RadioButton/Switch/AnimatedIcon already make for non-transform properties) — since the content's own onLayout measurement already includes its paddingTop, one animated height reproduces the web version's two-transition result. Content stays mounted while collapsed (accessibilityElementsHidden/importantForAccessibility="no", the same substitution for the web's inert used elsewhere in this package), and on first mount with defaultExpanded the panel briefly renders at its natural, unmeasured height so the initial reveal doesn't pop once layout resolves.

The chevron is PanEnd (GNOME's own pan-end-symbolic disclosure triangle) rotating 0deg → 90deg on an Animated.Value, the same interpolate-to- rotate recipe Spinner uses for its own spin.

Divider

import { Divider } from '@gnome-ui/react-native';

<Divider>OR</Divider>
<Divider>Continue with</Divider>
<Divider />

Horizontal rule with an optional centered label — the common auth/login-form pattern ("Sign in" / OR / "Continue with Google"). Mirrors @gnome-ui/react's Divider. For a bare dividing line with no label, use Separator instead — it also supports a vertical orientation, which Divider does not.

role="separator" ports 1:1 from RN's newer web-aligned Role union (the same one Avatar/Badge/LevelBar already reach for) — unlike Separator's own accessible={false}, since a labelled Divider ("OR") is exactly the kind of content a screen reader user needs read aloud, rather than a purely decorative line. The label reuses Text's variant="caption" color="dim" verbatim, which already resolves to the same font-size/weight/dim-opacity the web version's .label class hard-codes.

Highlight

import { Highlight } from '@gnome-ui/react-native';

<Highlight text="Preferences for accessibility" query="access" />
<Highlight text="The quick brown fox" query={['quick', 'fox']} />

Wraps every occurrence of query within text in a highlighted inline run — mirrors @gnome-ui/react's Highlight, which wraps matches in a <mark>. Pairs with SearchBar's suggestion list and any filterable list to show users which part of a result matched what they typed.

The outer span is the themed Text component (so callers get the same variant/color API as everywhere else), but each matched run is a plain, unthemed RN Text carrying only the highlight's own overrides — RN's Text is the one primitive that inherits ambient fontSize/color/fontFamily from a parent Text when nested, the same way the web version's <mark> inherits from its surrounding text and only overrides background-color/font-weight. Reaching for the themed Text for the marked runs too would reset them to its own default variant="body" sizing instead of inheriting whatever variant the caller chose for the whole string.

The web version's translucent color-mix(in srgb, accent 30%, transparent) background has no RN equivalent (color-mix is CSS-only) — resolved to a literal 8-digit #RRGGBBAA hex instead, since accentBgColor is always a plain 6-digit hex across all four theme variants. border-radius on the <mark> has no reliable port either: RN only paints backgroundColor on an inline (nested) Text run, not borderRadius — a decorative nicety dropped, not a behavior gap. prefers-contrast: more's solid-background/ white-text swap ports via useResolvedContrast(), the same hook Button already uses for its own high-contrast branching.

FileTypeIcon

import { FileTypeIcon } from '@gnome-ui/react-native';

<FileTypeIcon name="report.pdf" />
<FileTypeIcon mimeType="image/png" />
<FileTypeIcon name="cover.jpg" thumbnail={thumbnailUrl} />
<FileTypeIcon isFolder />

Small icon — optionally a thumbnail — resolved from a file's MIME type or name extension. Useful for file-manager-style listings. Mirrors @gnome-ui/react's FileTypeIcon, falling back to the generic file icon (freedesktop's text-x-generic) when the type can't be resolved.

fileType.ts's category-resolution logic (MIME type / extension → one of 13 categories, plus the freedesktop icon and generated label per category) is pure, DOM-free TS — duplicated verbatim from @gnome-ui/react rather than imported cross-package, the same Icon.tsx precedent already established for logic that isn't worth a shared package for one file's worth of code. role="img" + accessibilityLabel ports 1:1, and the thumbnail reuses Avatar's own Image/resizeMode="cover" recipe, sized from Icon's own size map so swapping between the resolved icon and a thumbnail never shifts layout.

Chip

import { Chip } from '@gnome-ui/react-native';

<Chip label="React" />
<Chip label="React" onRemove={() => {}} />
<Chip label="React" selectable selected={selected} onToggle={() => setSelected((s) => !s)} />

Compact pill-shaped label for tags, filters, and selection states. Mirrors @gnome-ui/react's Chip. Three usage modes: static (just a visual label), removable (add onRemove for a × button), and selectable (add selectable + selected + onToggle for toggle behavior — same isInteractive = selectable && !onRemove precedence as the web version, so passing both renders the remove button, not a toggle). Pair with WrapBox for multi-chip layouts.

The selected background/border tint (color-mix(in srgb, accent 15%/50%, transparent)) resolves to a literal 8-digit #RRGGBBAA hex, the same Highlight precedent. The web version's :hover/:active background transitions collapse into a single pressed-state overlay tinted by theme.activeOverlay (the same ActionRow/Card recipe), since touch has no hover. The leading icon and remove (×) icon stay in the default foreground color rather than tracking the selected accent text (color: inherit on the web) — RN's Icon has no currentColor equivalent and only accepts a fixed named-swatch palette, none of which tracks the app's configurable accent color, so this is a decorative nicety dropped, not a behavior gap. accessibilityRole="checkbox" on the selectable form ports 1:1, the same Checkbox precedent.

SegmentedBar

import { SegmentedBar } from '@gnome-ui/react-native';

<SegmentedBar
  values={[
    { label: 'TypeScript', value: 60, color: '#3178c6' },
    { label: 'JavaScript', value: 30, color: '#f7df1e' },
    { label: 'CSS',        value: 10, color: '#563d7c'