@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.
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, andAnimatedIcon(which brought a newIconcomponent along with it, as its own public component) andStatusPageshipped — Tier 4 complete. Tier 5 Advanced Controls fully ported:Dropdown,Slider,SpinButton,Avatar,Badge, andPopover. Beyond Tier 5,BottomSheet(Tier 14) andOverlay/LevelBar/Expander/Divider/Highlight/FileTypeIcon/SegmentedBar/AvatarGroup/AvatarRotator/CoachMark/CoachMarkTour(Tier 20),Chip(Tier 7),IconButton/Drawer(Tier 8/Tier 20), andClamp(Tier 6),Box(Tier 20),WrapBox/ToggleGroup(Tier 7), andInlineViewSwitcher(Tier 8),PreferencesGroup(Tier 13), andEntryRow/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), andMultiSelectDropdown(Tier 20) also shipped, along withFilterableMultiSelectDropdown— an original@gnome-ui/react-only component (not a GNOME HIG port) built once its prerequisiteMultiSelectDropdownlanded — andPasswordField/RangeSlider/StatusBadge/WidgetManager(all Tier 20) —WidgetManagerwas previously deferred as low-priority, built once named directly since every piece it composes had already shipped.BottomTabBaralso shipped — a React Native-only original with no@gnome-ui/reactsource 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.useBreakpointandBreakpointBin(both Tier 6) also shipped — the package's first adaptive-layout primitives, built onuseWindowDimensionsandonLayoutrespectively since there's no CSS media query/container query to lean on.ButtonContent(Tier 15) also shipped — an icon+label layout helper mostly redundant withButton's ownleadingIcon/trailingIcon, kept for composing the same spacing outsideButtonitself.TagInput(Tier 20) also shipped — aWrapBox+Chipcomposition, unblocked once both had shipped. Component ports from@gnome-ui/reactcontinue tier by tier — see this package's own ROADMAP.md for full per-tier status against all 130@gnome-ui/reactcomponents, 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; // 12The 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-1–title-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' 