A soft tile behind the active tab glides to the tapped one.
37·Bottom TabsNew
Dependencies
nativewindreact-native-reanimatedreact-native-workletsreact-native-safe-area-contexttailwindcssclsxtailwind-mergephosphor-react-nativereact-native-svg
A floating bottom bar where the active tab sits on a soft rounded tile. Tap another tab and the tile eases across
to it: quick to start, soft to stop, no bounce. The same motion as the Glide Tab Bar,
with a highlight instead of an underline, so the selection reads at a glance even with labels on.
Pre-release
The skiddie CLI isn't published yet. The commands below are the planned ones and won't work until v0.1.
npx skiddie@latest add highlight-tab-bar
If you haven't set skiddie up yet, run init first (Installation).
Every skiddie component comes in two versions with the same props and motion. Pick one:
- NativeWind (default): styled with
className. Needs NativeWind v4.
- StyleSheet: plain React Native
StyleSheet, no styling library. Add --stylesheet.
use-highlight-indicator.ts | File | What it does |
|---|
| highlight-tab-bar/index.tsx | The component |
| highlight-tab-bar/styles.ts | Default classes per slot |
| highlight-tab-bar/types.ts | Props, with docs |
| highlight-tab-bar/use-highlight-indicator.ts | Shared logic (hook) |
| core/tokens.ts | Colors, radii, spacing, motion |
| core/tailwind-preset.js | Turns tokens into sk-* classes |
| core/cn.ts | Merges your classes last |
| core/types.ts | Shared prop contract |
| tailwind.config.js | init adds the skiddie preset and folder |
Only the highlighted files are this component. core/ is shared by every skiddie component and copied once by
init.
No CLI? Install the dependencies and copy the files yourself. Pick the styling version:
1
Install the dependencies:
npx expo install nativewind react-native-reanimated react-native-worklets react-native-safe-area-context tailwindcss clsx tailwind-merge
2
Install an icon set. The examples use Phosphor; any icon library works:
npx expo install phosphor-react-native react-native-svg
3
Set up NativeWind v4, then add the skiddie preset and folder to tailwind.config.js:
module.exports = { content: [ './app/**/*.{js,jsx,ts,tsx}', './components/skiddie/**/*.{js,jsx,ts,tsx}', ], presets: [ require('nativewind/preset'), require('./components/skiddie/core/tailwind-preset'), ],};
4
Copy the shared files into components/skiddie/core/ (once, for every skiddie component):
components/skiddie/core/tokens.ts /** * skiddie design tokens: the single source of truth. * * `tailwind-preset.js` turns these into `sk-*` Tailwind classes (CSS variables, so light/dark * switch automatically). Components read this file directly only for values needed in JS, * e.g. inside Reanimated worklets. Edit values here, then restart Metro so Tailwind rebuilds. */export const tokens = { colors: { light: { background: '#FFFFFF', surface: '#F4F4F5', active: '#0A0A0A', inactive: '#A1A1AA', indicator: '#0A0A0A', badge: '#EF4444', badgeText: '#FFFFFF', border: '#E4E4E7', }, dark: { background: '#0A0A0A', surface: '#18181B', active: '#FAFAFA', inactive: '#71717A', indicator: '#FAFAFA', badge: '#EF4444', badgeText: '#FFFFFF', border: '#27272A', }, }, radius: { bar: 28, card: 12, item: 20, indicator: 999 }, spacing: { barPaddingH: 12, barPaddingV: 8, itemPaddingV: 16, itemGap: 4, floatingInset: 16, // Default gap between a floating bottom bar and the bottom safe area, so it clears the home indicator. floatingOffset: 23, badgeOffsetX: 8, badgeOffsetY: 4, badgePaddingH: 4, // Gap between a highlight tile and its tab's edges. tileInset: 4, }, // Sizes in px. Use these, not Tailwind's rem classes (py-4, h-1…): NativeWind makes 1rem 14px on native and 16px // on web, so rem classes would differ from the StyleSheet versions (ADR 0026). size: { indicatorThickness: 4, badge: 16, badgeDot: 8, // The cutout bar's notch: opening width at the top edge, depth, and the dot inside it. notchWidth: 44, notchDepth: 10, notchDot: 6, }, typography: { label: { fontSize: 11, lineHeight: 14, letterSpacing: 0.2 }, badge: { fontSize: 10 } }, motion: { spring: { damping: 18, stiffness: 220, mass: 1 }, durationMs: 220 }, hitSlop: 8, minTouchTarget: 44,} as const;export type SkiddieTokens = typeof tokens;export type SkiddieColorName = keyof SkiddieTokens['colors']['light'];
components/skiddie/core/tailwind-preset.js // skiddie Tailwind preset for NativeWind v4 (ADR 0020).// Add to tailwind.config.js: presets: [require('nativewind/preset'), require('./components/skiddie/core/tailwind-preset')]// and add the skiddie folder to `content`, or the components render unstyled.//// Values come from tokens.ts. Tailwind loads configs through jiti, which transpiles the TS import.const { tokens } = require('./tokens.ts');/** '#0A0A0A' -> '10 10 10' (channels, so `/ <alpha-value>` opacity modifiers work) */function channels(hex) { const value = hex.replace('#', ''); const full = value.length === 3 ? value .split('') .map((c) => c + c) .join('') : value; const n = Number.parseInt(full, 16); return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;}/** camelCase token name -> kebab-case class/variable name: badgeText -> badge-text */function kebab(name) { return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);}function cssVars(scheme) { return Object.fromEntries( Object.entries(tokens.colors[scheme]).map(([name, hex]) => [`--sk-${kebab(name)}`, channels(hex)]), );}const colorNames = Object.keys(tokens.colors.light);/** @type {import('tailwindcss').Config} */module.exports = { theme: { extend: { colors: { sk: Object.fromEntries( colorNames.map((name) => [kebab(name), `rgb(var(--sk-${kebab(name)}) / <alpha-value>)`]), ), }, borderRadius: { 'sk-bar': `${tokens.radius.bar}px`, 'sk-card': `${tokens.radius.card}px`, 'sk-item': `${tokens.radius.item}px`, 'sk-indicator': `${tokens.radius.indicator}px`, }, spacing: { 'sk-bar-x': `${tokens.spacing.barPaddingH}px`, 'sk-bar-y': `${tokens.spacing.barPaddingV}px`, 'sk-gap': `${tokens.spacing.itemGap}px`, 'sk-inset': `${tokens.spacing.floatingInset}px`, 'sk-touch': `${tokens.minTouchTarget}px`, 'sk-item-y': `${tokens.spacing.itemPaddingV}px`, 'sk-badge-x': `${tokens.spacing.badgeOffsetX}px`, 'sk-badge-y': `${tokens.spacing.badgeOffsetY}px`, 'sk-badge-pad': `${tokens.spacing.badgePaddingH}px`, 'sk-tile': `${tokens.spacing.tileInset}px`, 'sk-indicator': `${tokens.size.indicatorThickness}px`, 'sk-badge': `${tokens.size.badge}px`, 'sk-dot': `${tokens.size.badgeDot}px`, }, fontSize: { 'sk-label': [ `${tokens.typography.label.fontSize}px`, { lineHeight: `${tokens.typography.label.lineHeight}px`, letterSpacing: `${tokens.typography.label.letterSpacing}px`, }, ], 'sk-badge': `${tokens.typography.badge.fontSize}px`, }, }, }, plugins: [ ({ addBase }) => addBase({ ':root': cssVars('light'), // System appearance (NativeWind's default `darkMode: 'media'`) '@media (prefers-color-scheme: dark)': { ':root': cssVars('dark') }, // Apps that switch manually with `darkMode: 'class'` '.dark:root': cssVars('dark'), }), ],};
components/skiddie/core/cn.ts import { type ClassValue, clsx } from 'clsx';import { extendTailwindMerge } from 'tailwind-merge';// tailwind-merge only knows Tailwind's own scales. Teach it the sk-* ones from tailwind-preset.js, or it drops// `text-sk-label` (a size) next to `text-sk-active` (a color) and can't let `rounded-none` replace `rounded-sk-bar`.// Keep these lists in step with the preset.const twMerge = extendTailwindMerge({ extend: { theme: { spacing: [ 'sk-bar-x', 'sk-bar-y', 'sk-gap', 'sk-inset', 'sk-touch', 'sk-item-y', 'sk-badge-x', 'sk-badge-y', 'sk-badge-pad', 'sk-tile', 'sk-indicator', 'sk-badge', 'sk-dot', ], borderRadius: ['sk-bar', 'sk-card', 'sk-item', 'sk-indicator'], }, classGroups: { 'font-size': [{ text: ['sk-label', 'sk-badge'] }], }, },});/** * Merge class names. Later classes win, so user `className` / `classNames` override * component defaults: cn('bg-sk-background px-2', className). */export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs));}
components/skiddie/core/types.ts import type { ReactNode } from 'react';import type { StyleProp, TextStyle, ViewStyle } from 'react-native';import type { EdgeInsets } from 'react-native-safe-area-context';/* * Shared prop contract for every tab-bar-like component (PLAN.md 2.3, ADR 0020). * Customization goes, in order of power: tokens → className / classNames → renderItem → animation. */export type TabIconRender = (p: { focused: boolean; color: string; size: number }) => ReactNode;export interface TabItem { /** Unique key; matches `activeKey` and is passed to `onChange`. */ key: string; /** Visible label, also the default accessibility label. */ label: string; icon?: TabIconRender; /** A count, a short string, or `true` for a dot. Announced to screen readers. */ badge?: number | string | boolean; accessibilityLabel?: string; testID?: string; disabled?: boolean;}export type TabBarSlot = | 'container' | 'bar' | 'item' | 'itemActive' | 'icon' | 'label' | 'labelActive' | 'indicator' | 'badge' | 'badgeText';export interface RenderItemProps { item: TabItem; focused: boolean; index: number; onPress: () => void;}export interface SpringConfig { damping?: number; stiffness?: number; mass?: number;}/** Everything a tab bar takes except styling; shared by the NativeWind and StyleSheet versions (ADR 0026). */export interface TabBarBehaviorProps { items: TabItem[]; activeKey: string; onChange: (key: string) => void; onLongPress?: (key: string) => void; /** Escape hatch; wins over classes and slot styles. */ style?: StyleProp<ViewStyle>; renderItem?: (p: RenderItemProps) => ReactNode; animation?: { type?: 'spring' | 'timing'; spring?: SpringConfig; durationMs?: number }; haptics?: boolean | 'light' | 'medium' | 'selection'; reducedMotion?: 'system' | 'always' | 'never'; /** Pad for the bottom safe area. Default `true`. */ safeArea?: boolean; /** Injected by the navigator adapter when available. */ insets?: EdgeInsets; testID?: string;}/** NativeWind version: style with classes. */export interface BaseTabBarProps<Slot extends string = TabBarSlot> extends TabBarBehaviorProps { /** Classes for the root. Merged last, so they win over defaults. */ className?: string; /** Classes per part. */ classNames?: Partial<Record<Slot, string>>;}/** Slots that render text; they take `TextStyle`, every other slot `ViewStyle`. */export type TextSlot = 'label' | 'labelActive' | 'badgeText';/** StyleSheet version: style with style objects. */export interface BaseTabBarStyleProps<Slot extends string = TabBarSlot> extends TabBarBehaviorProps { /** Styles per part, applied after the defaults, so they win. */ styles?: { [K in Slot]?: StyleProp<K extends TextSlot ? TextStyle : ViewStyle> };}
5
Copy the component into components/skiddie/highlight-tab-bar/:
components/skiddie/highlight-tab-bar/index.tsx import { cn } from '../core/cn';import { tokens } from '../core/tokens';import { useColorScheme } from 'nativewind';import { memo } from 'react';import { Pressable, Text, View } from 'react-native';import Animated from 'react-native-reanimated';import { useSafeAreaInsets } from 'react-native-safe-area-context';import { styles } from './styles';import type { HighlightTabBarProps } from './types';import { useHighlightIndicator } from './use-highlight-indicator';/** A soft tile behind the active tab glides to the tapped one. NativeWind version. */export const HighlightTabBar = memo(function HighlightTabBar({ items, activeKey, onChange, onLongPress, className, classNames, style, renderItem, animation, reducedMotion, showLabels = false, iconSize = 24, bottomOffset = tokens.spacing.floatingOffset, safeArea = true, insets, testID,}: HighlightTabBarProps) { const safe = useSafeAreaInsets(); // Icon colors are needed in JS, so they come from tokens. NativeWind's hook follows the system appearance and // apps that switch dark mode by hand. const { colorScheme } = useColorScheme(); const colors = tokens.colors[colorScheme === 'dark' ? 'dark' : 'light']; const bottom = safeArea ? (insets ?? safe).bottom : 0; const { indicatorStyle, onRowLayout } = useHighlightIndicator({ items, activeKey, animation, reducedMotion }); return ( <View role="tablist" testID={testID} className={cn(styles.container, classNames?.container, className)} style={[{ paddingBottom: Math.max(0, bottom + bottomOffset) }, style]} > <View className={cn(styles.bar, classNames?.bar)}> <View className="flex-1 flex-row" onLayout={onRowLayout}> {/* The tile, first so it sits behind the tabs. Plain View for the classes; Animated.View gets the motion. */} <View pointerEvents="none" className="absolute inset-0 flex-row"> <Animated.View style={indicatorStyle}> <View className={cn(styles.indicator, classNames?.indicator)} /> </Animated.View> </View> {items.map((item, index) => { const focused = item.key === activeKey; const onPress = () => onChange(item.key); if (renderItem) return renderItem({ item, focused, index, onPress }); const hasBadge = item.badge !== undefined && item.badge !== false; const spoken = item.badge === true ? 'new' : item.badge; const label = item.accessibilityLabel ?? item.label; return ( <Pressable key={item.key} role="tab" accessibilityState={{ selected: focused, disabled: item.disabled }} accessibilityLabel={hasBadge ? `${label}, ${spoken}` : label} disabled={item.disabled} onPress={onPress} onLongPress={onLongPress ? () => onLongPress(item.key) : undefined} testID={item.testID} className={cn( styles.item, classNames?.item, focused && cn(styles.itemActive, classNames?.itemActive), item.disabled && 'opacity-40', )} > <View className={cn(styles.icon, classNames?.icon)}> {item.icon?.({ focused, color: focused ? colors.active : colors.inactive, size: iconSize })} {hasBadge && ( <View className={cn(styles.badge, item.badge === true && 'size-sk-dot min-w-0 px-0', classNames?.badge)} > {item.badge !== true && ( <Text className={cn(styles.badgeText, classNames?.badgeText)}>{item.badge}</Text> )} </View> )} </View> {showLabels && ( <Text numberOfLines={1} className={cn( styles.label, classNames?.label, focused && cn(styles.labelActive, classNames?.labelActive), )} > {item.label} </Text> )} </Pressable> ); })} </View> </View> </View> );});export default HighlightTabBar;
components/skiddie/highlight-tab-bar/styles.ts /** Default classes per slot, from sk-* token classes. Users override any slot with `classNames`. */export const styles = { // Floating card inset from the screen edges; the safe-area bottom padding is added in index.tsx. container: 'px-sk-inset', // rounded-sk-card (12, like rounded-xl), not the pill-shaped rounded-sk-bar. bar: 'flex-row overflow-hidden rounded-sk-card border border-sk-border bg-sk-background px-sk-bar-x py-sk-bar-y', item: 'min-h-sk-touch flex-1 items-center justify-center gap-sk-gap py-sk-item-y', itemActive: '', icon: 'items-center justify-center', label: 'text-sk-label text-sk-inactive', labelActive: 'text-sk-active', // The glide track is one tab wide; the tile fills it, inset a little from the tab's edges. indicator: 'flex-1 mx-sk-tile rounded-sk-card bg-sk-surface', badge: 'absolute -end-sk-badge-x -top-sk-badge-y min-w-sk-badge rounded-sk-indicator bg-sk-badge px-sk-badge-pad', badgeText: 'text-center text-sk-badge font-semibold text-sk-badge-text',} as const;
components/skiddie/highlight-tab-bar/types.ts import type { BaseTabBarProps, BaseTabBarStyleProps } from '../core/types';interface HighlightOptions { /** * Show each tab's label under its icon. Default `false`: icon-only, like the inspiration. Labels are always * read by screen readers either way. */ showLabels?: boolean; /** Icon size in points, passed to each item's `icon` render function. Default `24`. */ iconSize?: number; /** * Gap in points between the bar and the bottom safe area (the screen edge when `safeArea` is `false`). Raise it * to push the bar up, lower it (negative works) to pull it down. Default `23`. */ bottomOffset?: number;}/** NativeWind version (`index.tsx`): style with `className` / `classNames`. */export interface HighlightTabBarProps extends BaseTabBarProps, HighlightOptions {}/** StyleSheet version (`stylesheet/index.tsx`): style with `styles`. */export interface HighlightTabBarStyleProps extends BaseTabBarStyleProps, HighlightOptions {}
components/skiddie/highlight-tab-bar/use-highlight-indicator.ts import { tokens } from '../core/tokens';import type { TabBarBehaviorProps } from '../core/types';import { useEffect } from 'react';import { I18nManager, type LayoutChangeEvent } from 'react-native';import { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming,} from 'react-native-reanimated';type Options = Pick<TabBarBehaviorProps, 'items' | 'activeKey' | 'animation' | 'reducedMotion'>;/** * The tile's motion, shared by both versions of the bar. * * A shared value holds the active *index*; the worklet multiplies it by the measured tab width on the UI thread. * A tap mid-animation starts from where the line is, rotating the device re-measures without animating, and no * frame goes through React state. */export function useHighlightIndicator({ items, activeKey, animation, reducedMotion = 'system' }: Options) { const systemReduced = useReducedMotion(); const reduce = reducedMotion === 'always' || (reducedMotion === 'system' && systemReduced); const count = items.length; const activeIndex = items.findIndex((item) => item.key === activeKey); const found = activeIndex >= 0; const rtl = I18nManager.isRTL; const rowWidth = useSharedValue(0); const position = useSharedValue(Math.max(activeIndex, 0)); // Ease-out by default: fast start, gentle stop, no overshoot. `animation={{ type: 'spring' }}` for a bounce. const type = animation?.type ?? 'timing'; const durationMs = animation?.durationMs ?? tokens.motion.durationMs; const damping = animation?.spring?.damping ?? tokens.motion.spring.damping; const stiffness = animation?.spring?.stiffness ?? tokens.motion.spring.stiffness; const mass = animation?.spring?.mass ?? tokens.motion.spring.mass; useEffect(() => { if (!found) return; if (reduce) position.value = activeIndex; else if (type === 'spring') position.value = withSpring(activeIndex, { damping, stiffness, mass }); else position.value = withTiming(activeIndex, { duration: durationMs, easing: Easing.out(Easing.cubic) }); }, [activeIndex, found, reduce, type, durationMs, damping, stiffness, mass, position]); const indicatorStyle = useAnimatedStyle(() => { const slot = count > 0 ? rowWidth.value / count : 0; return { width: slot, opacity: found && slot > 0 ? 1 : 0, // The track starts at the leading edge, which RTL already flips; transforms don't flip, so do it here. transform: [{ translateX: (rtl ? -1 : 1) * position.value * slot }], }; }); const onRowLayout = (event: LayoutChangeEvent) => { rowWidth.value = event.nativeEvent.layout.width; }; return { indicatorStyle, onRowLayout };}
6
Update the import paths to match your project, then use it as shown below.
The bar doesn't ship icons: each tab gets an icon render function, so any icon set works. The examples use
Phosphor, which matches the thin outline style:
npx expo install phosphor-react-native react-native-svg
Import each icon from its own file. Importing from 'phosphor-react-native' itself pulls every icon (about 1,500)
into your bundle.
import { HouseIcon } from 'phosphor-react-native/src/icons/House';
// app/(tabs)/_layout.tsximport { Tabs } from 'expo-router';import { HighlightTabBar } from '@/components/skiddie/highlight-tab-bar';import { createTabBarAdapter } from '@/components/skiddie/core/adapters/react-navigation';const TabBar = createTabBarAdapter(HighlightTabBar, { haptics: 'selection' });export default function TabLayout() { return ( <Tabs tabBar={(props) => <TabBar {...props} />} screenOptions={{ headerShown: false }}> <Tabs.Screen name="index" options={{ title: 'Home' }} /> <Tabs.Screen name="search" options={{ title: 'Search', tabBarBadge: 3 }} /> </Tabs> );}
import { useState } from 'react';
import { BellIcon } from 'phosphor-react-native/src/icons/Bell';
import { HeartIcon } from 'phosphor-react-native/src/icons/Heart';
import { HouseIcon } from 'phosphor-react-native/src/icons/House';
import { MagnifyingGlassIcon } from 'phosphor-react-native/src/icons/MagnifyingGlass';
import { UserIcon } from 'phosphor-react-native/src/icons/User';
import { HighlightTabBar } from '@/components/skiddie/highlight-tab-bar';
import type { TabItem } from '@/components/skiddie/core/types';
const items: TabItem[] = [
{ key: 'home', label: 'Home', icon: ({ color, size }) => <HouseIcon color={color} size={size} /> },
{ key: 'search', label: 'Search', icon: ({ color, size }) => <MagnifyingGlassIcon color={color} size={size} /> },
{ key: 'likes', label: 'Likes', icon: ({ color, size }) => <HeartIcon color={color} size={size} /> },
{
key: 'alerts',
label: 'Notifications',
badge: true,
icon: ({ color, size }) => <BellIcon color={color} size={size} />,
},
{ key: 'me', label: 'Profile', icon: ({ color, size }) => <UserIcon color={color} size={size} /> },
];
export function Bar() {
const [tab, setTab] = useState('home');
return <HighlightTabBar items={items} activeKey={tab} onChange={setTab} />;
}
The icon function gets focused, so you can switch weights for the active tab:
icon: ({ focused, color, size }) => <HouseIcon color={color} size={size} weight={focused ? 'fill' : 'regular'} />
Everything from the shared tab bar contract (items, activeKey, onChange,
onLongPress, className and classNames (NativeWind) or styles (StyleSheet), style, renderItem, animation, reducedMotion, safeArea,
insets, testID), plus:
| Prop | Type | Default | What it does |
|---|
showLabels | boolean | false | Show each label under its icon. Screen readers read labels either way. |
iconSize | number | 24 | Size passed to every icon function. |
bottomOffset | number | 23 | Gap between the bar and the bottom safe area (the screen edge with safeArea={false}). Raise it to push the bar up, lower it (negative works) to pull it down. |
The haptics prop is accepted but does nothing yet; it starts working when the shared haptics adapter lands.
In order of power:
-
Tokens. The tile uses surface, active icons active, the rest inactive, the bar background and
border (core/tokens.ts). Change them there and every skiddie component follows.
-
Classes per part with classNames. Your classes are merged last, so they win:
<HighlightTabBar
{...props}
classNames={{
container: 'px-0', // edge to edge instead of floating
bar: 'rounded-none border-x-0 border-b-0',
indicator: 'mx-0 rounded-sk-indicator', // a full-width pill
}}
/>
Slots: container, bar, item, itemActive, icon, label, labelActive, indicator, badge,
badgeText.
StyleSheet version: the same slots take style objects through styles:
<HighlightTabBar {...props} styles={{ bar: { borderRadius: 0 }, indicator: { marginHorizontal: 0, borderRadius: 999 } }} />
-
renderItem to draw each tab yourself. The tile keeps working, because it only needs the tab count.
-
animation for the motion:
<HighlightTabBar {...props} animation={{ durationMs: 160 }} /> // snappier ease-out
<HighlightTabBar {...props} animation={{ type: 'spring' }} /> // springy, overshoots a little
<HighlightTabBar {...props} animation={{ type: 'spring', spring: { damping: 12 } }} /> // bouncier
Every tab is the same width (flex-1). The bar measures its row once (onLayout) and keeps the active tab's
index in a Reanimated shared value. On the UI thread, the tile's translateX is index × tab width, so a
tap starts a new ease-out from wherever the tile is now, even mid-animation, and rotating the device just re-measures
without a jump. React only re-renders when the active tab changes, never per frame. The tile is drawn first in the row, so it sits
behind the icons and labels.
- The bar is a
tablist; each tab is a tab with selected and disabled state.
- Labels are read even when hidden, together with the selected state.
- Badges are part of the label: a count reads "Search, 3", a dot reads "Notifications, new".
- Tabs are at least 44pt tall and share the full bar width.
- With Reduce Motion on (iOS) or Remove animations (Android), the tile moves without animating. Force
it with
reducedMotion="always" or "never".
- RTL: tabs and the tile mirror automatically.
| Platform | Support |
|---|
| iOS | Yes |
| Android | Yes |
| Web (live preview) | Yes |
| Expo Go | TBD |
| Minimum Expo SDK | TBD |
| License | skiddie Free |
Works in Expo Go: it only needs Reanimated and safe-area-context, which Expo Go includes.