Primitives
Animated Number Avatar Badge Button Button Group Toggle Toggle Group Checkbox Chip Countdown Divider Fab Grid Icon Image Input Search Input Textarea Label Loader Marquee Masked Input Message Bubble Notification Banner Offline Banner Progress Progress Ring Radio Group Range Slider Rating Separator Skeleton Slider Stack Stepper Switch Text Theme Toggle Ticker Typing IndicatorComposed
Accordion Achievement Badge Action Bar Action Sheet Action Swap Activity Rings Add to Cart Button Address Card Agenda List Alert Amount Keypad Animated Icons App Bar Audio Message Audio Recorder Autocomplete Avatar Group Balance Display Bar List Barcode Before After Biometric Gate Bloom Menu Bottom Sheet Paywall Sheet Side Sheet Breadcrumb Calendar Calendar Strip Card Carousel Cart Item Character Charts Chat Composer Chat List Item Choice Card Circle Reveal Coach Marks Collapsible Collapsible Header Color Picker Combobox Command Palette Comment Thread Confetti Consent Sheet Context Menu Copy Field Country Picker Coupon Input Currency Input Data Table Date Range Picker Day Schedule Dialog Dock Donut Chart Drawer Driver Card Dropdown Menu Drum Picker Emoji Picker Empty State Error State Event Card Expandable Control Fader Feature Gate File Picker Filter Bar Follow Button Form Gauge Glass Glass Cluster Glass Dock Morph Glass Fab Morph Glass Lab Glass Orb Glass Search Morph Glass Sheet Edge Gradient Button Headline New Heatmap Image Cropper Image Viewer Kanban Card Keyboard Avoiding Keyboard Toolbar Leaderboard Level Progress Lightbox Like Button Line Chart List Reorderable List Swipeable Row Location Card Location Search Map Sheet Map View Markdown View Media Controls Media Picker Media Tile Mention Input Meter Bar Mini Player Mood Slider Morphing Modal Multi Select Navigation Rail Notification Item Number Input Onboarding Pager Order Card Order Summary OTP Input Pagination Paper Clip New Parallax Header Pass Card Password Strength Payment Button Payment Card Payment Method Permission Primer Phone Input PIN Pad Polaroid New Poll Popover Post Card Postage Stamp New Price Tag Pricing Table Product Card Product Gallery Profile Header Promo Banner Prompt Input Property Card Pull To Refresh QR Code Quick Actions Quick Replies Rating Summary Reaction Picker Resend Timer Review Card Review Prompt Ride Option Save Button Scan Frame Scroll Spy Tabs Seat Map Section Header Section Index Segmented Control Select Share Button Share Sheet Shutter Button Signature Pad Slide to Confirm Slot Picker Smart Banner Social Login Button Sparkline Splash Screen Split Button Stat Card Stateful Button Status Dot Step Flow Steps Sticker New Stopwatch Stories Progress Stories Viewer Story Ring Streak Counter Streaming Text Success Seal Swipe Deck Tabs Tag Input Tape New Text Reveal Thinking Indicator Time Picker Timeline Timer Ring Tip Selector Toast Toolbar Tooltip Track Row Transaction Row Trip Progress Variant Picker Video Controls Waveform Weather Card Weekday Picker Zoom TransitionScreens
Account Switcher Biometric Setup Delete Account Forgot Password Grow Auth Login Login — Dark Stage Login — Dense Work Login — Editorial Login — Illustrated Login — Minimal Ink Login — Social Login — Video Loop Food Onboarding Onboarding Onboarding Dark Objects Onboarding Editorial Onboarding Illustrated Onboarding Personalize Onboarding Photo Carousel OTP Verification Passkey — Create Password Reset Permission — Notifications Phone Verification Onboarding Quiz Session Devices Signup Sign-up Chat Sign-up Photo Sheet Social Auth Split Auth Verify Identity Welcome AI Chat AI Assistant Channels List Chat Info Chat List Chat Media Chat Thread Create Group Group Info Message Requests New Message Kept Messages Accessibility Activity Log Appearance Authenticator Blocked Accounts Export Your Data Downloads Edit Profile Edit Profile — Rows Emergency Call Empty States Set Error State Send Feedback Device Locator Help Center Home — Bento Home — Dark Home — Editorial Home Feed Invite Friends Language & Region Linked Accounts Not Found (404) Notification Settings Notifications Password Vault Paywall Paywall — Comparison Paywall — Dark Stage Paywall — Editorial Paywall — Photo Paywall — Trial Timeline Privacy Profile Profile — Gamified Profile — Minimal Profile — Photo Hero QR Scanner Report Issue Saved Items Phone Time Search Search — Command Palette Search — Visual Account Health Settings Settings — Dark Device Settings — Search Storage & Data Terms & Privacy Dating Chat Dating Filters Profile Discover Its A Match Into You Matches Profile Detail Profile Editor Courier Matching Delivery History Delivery Support Package Schedule Parcel Tracking Proof Of Delivery Quick Aisle Quick Home Send Package Substitutions Add Payment Address Book Cart Categories Checkout Checkout — Steps Checkout Success Delivery Filters & Sort Flash Sale Gift Cards Loyalty Points Order Cancel Order Detail Orders Pay Later Checkout Payment Methods Product Detail Product Detail — Hero Product Grid Product Reviews Promo Codes Recently Viewed Return Items Search Results Shop Assistant Store Home Tip & Rate Courier Order Tracking Wishlist Write a Review Event Checkin Event Hero Events Discover My Tickets Seat Selection Ticket Checkout Add Card Bank Accounts Dashboard — Bento Bill Pay Budgets Card Details Crypto Portfolio Finance Dashboard Dashboard — Dark Exchange Currency Expense List Invoice Detail Manage Cards Plan Management Savings Goals Send Money Spending Analytics Split Bill Subscriptions Token Detail Token Swap Transactions Wallet Home Wallet Movers Courier Tracking Food Cart Food Home Food Orders Grocery List Recipe Detail Restaurant — Dark Restaurant Detail Activity Rings Fitness Home Habit Quest Habit Tracker Meal Planner Med Store Meditation Player Mood Check-in Nutrition Home Period Tracker Prior Auth Prior Auth Cases Quest Complete Run Tracking Rx Scan Sleep Tracker Telehealth Booking Walk Map Water Tracker Workout Complete Workout Detail Workout Session Applications Apply Company Interview Schedule Job Detail Job Feed Resume Saved Jobs Create Listing Item Chat Listing Detail Make Offer Market Checkout Market Home Market Search My Listings Offers Inbox Seller Profile Audiobook Player Camera Gallery Albums Game Store Mini Player Music Player Photo Editor Photo Gallery Playlist Detail Podcast Player Clips Feed Serene Home Sound Mixer Stories Video Call Video Player Article Reader Course Detail Event Detail Memory Onboarding Icon Lab Leaderboard Lesson Player Match Detail News Editorial News Home Plant Care Prediction Live Prediction Welcome Smart Home Standings Ticket Wallet Topics Follow Weather Detail Add Task Sheet Alarm Clock Calendar Day Calendar Month Doc Scanner File Manager Focus Timer Journal Kanban Board Mail Inbox Mail Thread Note Editor Notes List Reminders Task List Time Blocks Agent Profile Compare Homes Mortgage Calculator Property Detail Rental Application Saved Homes Schedule Tour Search List Search Map Complete Destination Driver Arriving Ev Charging History Home In Trip Matching Options Parking Receipt Safety Schedule Scooter Unlock Booking Confirmed Booking Review Booking Slots My Bookings Pro On The Way Provider Detail Provider List Rate Service Services Home Arc Create Arc Detail Arc Onboarding Avatar Creator Comments Sheet Create Post Explore Grid Followers List Hashtag Feed Mentions Post Detail Post Hero Social Profile Boarding Pass Booking Confirm Car Rental Trip Discover Flight Live Flight Preferences Flight Results Flight Search Hotel Detail Live Transit Trip — Map Day Trip — Rideshare Request Seat Select Travel Home Trip Itinerary Trip OnboardingNothing matches — try another word.
App Bar
Top navigation bar that can follow your scroll: its background and edge fade in as content passes underneath, titles swap smoothly, and a floating style frosts over. Includes a large-title style.
Preview
import * as React from "react";
import { forwardRef } from "react";
import { View, Text, Pressable, type ViewProps } from "react-native";
import Animated, {
Easing,
interpolate,
Extrapolation,
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
type SharedValue,
} from "react-native-reanimated";
import {
cn,
useHaptic,
usePressAnimation,
useReducedMotion,
useTheme,
AnimatedPressable,
LitEdge,
ease,
spring,
tempo,
timing,
} from "@lumioui/core";
import { Icon, type IconName } from "../icon";
import { Glass, GlassContainer } from "../glass";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export interface AppBarActionItem {
/** Lucide icon name. */
icon: IconName;
/** Required — icon buttons carry no visible label. */
accessibilityLabel: string;
onPress?: () => void;
/** Disabled state — dims and drops the haptic. */
disabled?: boolean;
/** Committed state (saved, liked, watching). The glyph fills, and turning
* on plays the commit: the glyph dips and re-ignites while an accent wash
* holds on the button. */
active?: boolean;
}
export interface AppBarProps extends Omit<ViewProps, "className"> {
/** Bar title. "small" centers it inline; "large" renders a large title below the row. */
title?: string;
/** Secondary line under the centered title (small mode only). */
subtitle?: string;
/** "small" = classic inline bar; "large" = large-title header. Default "small". */
size?: "small" | "large";
/** Show a back chevron + label on the leading edge. */
showBack?: boolean;
/** Label next to the back chevron — conventionally the previous screen's title. Default "Back". */
backLabel?: string;
onBack?: () => void;
/** Leading slot — overrides the back button when set. */
leading?: React.ReactNode;
/** Trailing action icon buttons. */
actions?: AppBarActionItem[];
/** Trailing slot — overrides `actions` when set. */
trailing?: React.ReactNode;
/** Pad for the status bar. Default true. */
safeArea?: boolean;
/** Bottom hairline. Default true. */
bordered?: boolean;
/** Translucent bar: edge-to-edge glass material for content scrolling
* underneath, and the bar's buttons wear glass capsules — the bar is
* transparent and the controls carry the glass.
* Adjacent trailing actions union into one pill. */
translucent?: boolean;
/** Floating capsule bar — the bar detaches into a rounded glass capsule
* hovering over the content (island bar). Implies translucent. */
floating?: boolean;
/** Haptic on button presses. Default "light". */
haptic?: "light" | "selection" | false;
/** Scroll offset of the content under the bar. When passed, light follows
* scroll: the surface, its frost and the bottom hairline are clear at rest
* and fully on by 24pt (floating capsules sharpen instead). Drive it from
* `useAnimatedScrollHandler` — it is read on the UI thread. */
scrollY?: SharedValue<number>;
className?: string;
}
/** Scroll distance over which the bar's surface and hairline switch on. */
const SCROLL_LIGHT = 24;
/** How far a floating capsule's frost veil thickens once content is under it. */
const CAPSULE_FROST = 0.5;
/** Swapped titles ignite from this scale (DESIGN-TASTE §5 rule 3). */
const SWAP_FROM = 0.96;
/** A committed action holds its accent wash for one settle beat so the eye catches it. */
const WASH_HOLD = tempo.settle;
/** Peak alpha of that wash — a tint on the button, not a fill. */
const WASH_PEAK = 0.16;
/** The glyph dips to here before re-igniting on commit. */
const COMMIT_DIP = 0.84;
/** 0 at rest → 1 once content has scrolled SCROLL_LIGHT under the bar. */
function useScrollLight(scrollY: SharedValue<number> | undefined, peak = 1) {
return useAnimatedStyle(() => ({
opacity: scrollY
? interpolate(scrollY.value, [0, SCROLL_LIGHT], [0, peak], Extrapolation.CLAMP)
: peak,
}));
}
/** A line of text that swaps in place: the outgoing copy dims while the
* incoming one ignites. Only the incoming copy is read by screen readers. */
function SwapText({
text,
className,
align = "center",
header,
}: {
text: string;
className: string;
align?: "center" | "left";
header?: boolean;
}) {
const reduce = useReducedMotion();
const [shown, setShown] = React.useState(text);
const [out, setOut] = React.useState<string | null>(null);
if (text !== shown) {
setOut(shown);
setShown(text);
}
const lit = useSharedValue(1);
const grow = useSharedValue(1);
const leave = useSharedValue(0);
const mounted = React.useRef(false);
React.useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
lit.value = 0;
lit.value = withTiming(1, { ...timing.fast, easing: Easing.bezier(...ease.out) });
leave.value = 1;
leave.value = withTiming(0, { ...timing.recede, easing: Easing.bezier(...ease.in) });
if (reduce) {
grow.value = 1;
return;
}
grow.value = SWAP_FROM;
grow.value = withSpring(1, spring.swap);
}, [shown, reduce, lit, grow, leave]);
const inStyle = useAnimatedStyle(() => ({
opacity: lit.value,
transform: [{ scale: grow.value }],
}));
const outStyle = useAnimatedStyle(() => ({
opacity: leave.value,
transform: [{ scale: reduce ? 1 : SWAP_FROM + (1 - SWAP_FROM) * leave.value }],
}));
const textAlign = align === "center" ? "center" : "left";
return (
<View className={align === "center" ? "items-center" : undefined}>
<Animated.Text
className={className}
style={[{ textAlign }, inStyle]}
numberOfLines={1}
accessibilityRole={header ? "header" : undefined}
>
{shown}
</Animated.Text>
{out != null ? (
<Animated.Text
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
className={cn("absolute left-0 right-0 top-0", className)}
style={[{ textAlign }, outStyle]}
numberOfLines={1}
>
{out}
</Animated.Text>
) : null}
</View>
);
}
/**
* AppBar — navigation bar: back slot, centered title (or large title),
* trailing action icons. Safe-area aware.
* <AppBar title="Album" showBack backLabel="Albums" onBack={goBack}
* actions={[{ icon: "share", accessibilityLabel: "Share" }]} />
* Expo Go compatible.
*/
/** Translucent bars use Lumio glass (the native LiquidGlassView on iOS 26+
* when installed, frosted backdrop-blur fallback); solid bars keep the theme
* surface. The border keeps its separator role in both modes. */
const BarSurface = forwardRef<
React.ElementRef<typeof View>,
Omit<ViewProps, "className"> & {
translucent?: boolean;
bordered?: boolean;
safeAreaTop: number;
scrollY?: SharedValue<number>;
className?: string;
}
>(function BarSurface(
{ translucent, bordered, safeAreaTop, scrollY, className, children, ...props },
ref
) {
const lightStyle = useScrollLight(scrollY);
if (scrollY) {
// Light follows scroll — the material and its hairline are layers whose
// opacity is the scroll offset, so the bar is clear over the hero and
// fully frosted by the time content runs under it.
return (
<View
ref={ref}
className={cn("w-full", className)}
style={safeAreaTop ? { paddingTop: safeAreaTop } : undefined}
accessibilityRole="toolbar"
{...props}
>
<Animated.View pointerEvents="none" className="absolute inset-0" style={lightStyle}>
{translucent ? (
<Glass effect="regular" className="h-full w-full" />
) : (
<View className="h-full w-full bg-background" />
)}
</Animated.View>
{children}
{bordered ? (
<Animated.View
pointerEvents="none"
className="absolute bottom-0 left-0 right-0 h-px bg-border"
style={lightStyle}
/>
) : null}
</View>
);
}
if (translucent) {
return (
<Glass
ref={ref}
effect="regular"
className={cn(
"w-full",
bordered && "border-b border-border",
className
)}
style={safeAreaTop ? { paddingTop: safeAreaTop } : undefined}
accessibilityRole="toolbar"
{...props}
>
{children}
</Glass>
);
}
return (
<View
ref={ref}
className={cn(
"w-full",
"bg-background",
bordered && "border-b border-border",
className
)}
style={safeAreaTop ? { paddingTop: safeAreaTop } : undefined}
accessibilityRole="toolbar"
{...props}
>
{children}
</View>
);
});
export const AppBarRoot = forwardRef<React.ElementRef<typeof View>, AppBarProps>(
function AppBar(
{
title,
subtitle,
size = "small",
showBack = false,
backLabel = "Back",
onBack,
leading,
actions,
trailing,
safeArea = true,
bordered = true,
translucent = false,
haptic = "light",
className,
floating = false,
scrollY,
...props
},
ref,
) {
const insets = useSafeAreaInsets();
const { scheme } = useTheme();
const frostStyle = useScrollLight(scrollY, CAPSULE_FROST);
const large = size === "large";
const glassBar = floating || translucent;
// The centered title clears whichever side slot is wider — a fixed inset
// collides with a back label + three trailing actions. Both sides use the
// same inset so the title stays optically centered on the bar.
const [sideW, setSideW] = React.useState({ l: 0, r: 0 });
const titleInset = Math.max(64, sideW.l, sideW.r) + 4;
const barRow = (
<View className="min-h-11 w-full flex-row items-center justify-between px-1">
<View
className="min-w-11 flex-row items-center"
onLayout={(e) => {
const l = Math.round(e.nativeEvent.layout.width);
setSideW((p) => (p.l === l ? p : { ...p, l }));
}}
>
{leading ??
(showBack ? (
glassBar ? (
<Glass interactive effect="clear" className="rounded-full">
<AppBarBack label={backLabel} onPress={onBack} haptic={haptic} tone="foreground" />
</Glass>
) : (
<AppBarBack label={backLabel} onPress={onBack} haptic={haptic} />
)
) : null)}
</View>
{title != null && !large ? (
<View
pointerEvents="none"
className="absolute bottom-0 top-0 items-center justify-center"
style={{ left: titleInset, right: titleInset }}
>
<SwapText
text={title}
header
className="text-[17px] font-semibold leading-tight text-foreground"
/>
{subtitle != null ? (
<SwapText text={subtitle} className="text-xs leading-tight text-muted-foreground" />
) : null}
</View>
) : null}
<View
className="min-w-11 flex-row items-center justify-end"
onLayout={(e) => {
const r = Math.round(e.nativeEvent.layout.width);
setSideW((p) => (p.r === r ? p : { ...p, r }));
}}
>
{trailing ??
(glassBar && actions && actions.length > 0 ? (
/* Adjacent bar items share one glass — a union
container melts the action discs into a single pill. */
<GlassContainer
spacing={4}
effect="clear"
className="flex-row items-center rounded-full"
>
{actions.map((action, i) => (
<Glass
key={`${action.icon}-${i}`}
interactive
effect="clear"
className="rounded-full"
>
<AppBarAction {...action} haptic={haptic} tone="foreground" />
</Glass>
))}
</GlassContainer>
) : (
actions?.map((action, i) => (
<AppBarAction key={`${action.icon}-${i}`} {...action} haptic={haptic} />
))
))}
</View>
</View>
);
if (floating) {
// Island bar — a glass capsule hovering over the content, no edge
// background or hairline at all.
return (
<View
ref={ref}
className={cn("w-full px-3", className)}
style={safeArea ? { paddingTop: insets.top + 6 } : undefined}
{...props}
>
<Glass effect="regular" className="w-full rounded-full">
{/* Frost thickens as content scrolls under the capsule. */}
{scrollY ? (
<Animated.View
pointerEvents="none"
className="absolute inset-0 rounded-full bg-background"
style={frostStyle}
/>
) : null}
{barRow}
{large && title != null ? (
<LargeTitle title={title} subtitle={subtitle} />
) : null}
{/* Lit from above — the capsule's top edge catches light in dark scheme. */}
{scheme === "dark" ? <LitEdge radius={999} /> : null}
</Glass>
</View>
);
}
return (
<BarSurface
ref={ref}
translucent={translucent}
bordered={bordered}
safeAreaTop={safeArea ? insets.top : 0}
scrollY={scrollY}
className={className}
{...props}
>
{barRow}
{/* Large title — the oversized heading parks under the row and
collapses it into the inline slot on scroll. This bar renders it
STATICALLY on purpose: scroll-linked collapse lives in
CollapsibleHeader — use that component instead of stacking both. */}
{large && title != null ? (
<LargeTitle title={title} subtitle={subtitle} />
) : null}
</BarSurface>
);
},
);
/** Large title + subtitle under the row; both swap in place on change. */
function LargeTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<View className="w-full px-4 pb-2 pt-0.5">
<SwapText
text={title}
header
align="left"
className="text-[34px] font-bold leading-10 text-foreground"
/>
{subtitle != null ? (
<SwapText text={subtitle} align="left" className="text-sm leading-tight text-muted-foreground" />
) : null}
</View>
);
}
/** Control tint — "primary" (the accent, classic solid bars) or
* "foreground" (monochrome glass capsules over content). */
type AppBarTone = "primary" | "foreground";
export interface AppBarBackProps {
/** Visible label next to the chevron. Default "Back". */
label?: string;
/** Chevron + label tint. Default "primary"; glass bars pass "foreground". */
tone?: AppBarTone;
onPress?: () => void;
haptic?: "light" | "selection" | false;
className?: string;
accessibilityLabel?: string;
}
/** Back chevron + label — also usable standalone in the `leading` slot. */
// Refs land on the Pressable host instance inside AnimatedPressable
// (React.ElementRef<typeof AnimatedPressable> isn't valid — the shared
// singleton is typed ComponentType, not a ref-forwarding component).
export const AppBarBack = forwardRef<React.ElementRef<typeof Pressable>, AppBarBackProps>(
function AppBarBack({ label = "Back", tone = "primary", onPress, haptic = "light", className, accessibilityLabel }, ref) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.94, pressedOpacity: 0.6 });
return (
<AnimatedPressable
ref={ref}
onPress={() => {
if (haptic) haptics[haptic]();
onPress?.();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
hitSlop={8}
className={cn("h-11 flex-row items-center gap-0.5 pr-2", className)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? `Back${label && label !== "Back" ? ` to ${label}` : ""}`}
>
<Icon name="chevron-left" size="lg" color={tone === "foreground" ? "foreground" : "primaryText"} />
{label ? (
<Text
className={cn("text-[17px] leading-tight", tone === "foreground" ? "text-foreground" : "text-primary-text")}
numberOfLines={1}
>
{label}
</Text>
) : null}
</AnimatedPressable>
);
},
);
export interface AppBarActionProps extends AppBarActionItem {
haptic?: "light" | "selection" | false;
/** Icon tint. Default "primary"; glass bars pass "foreground". */
tone?: AppBarTone;
className?: string;
}
/** Trailing action icon button — also usable standalone in the `trailing` slot. */
export const AppBarAction = forwardRef<React.ElementRef<typeof Pressable>, AppBarActionProps>(
function AppBarAction(
{ icon, accessibilityLabel, onPress, disabled, active, haptic = "light", tone = "primary", className },
ref,
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const press = usePressAnimation({ pressedScale: 0.9, pressedOpacity: 0.6, disabled });
const wash = useSharedValue(0);
const glyph = useSharedValue(1);
const was = React.useRef(active);
React.useEffect(() => {
// Commit — act, hold, resolve: the wash ignites, holds a beat, dims;
// the glyph dips and re-ignites filled. Turning off just dims back.
if (active && !was.current) {
wash.value = withSequence(
withTiming(1, { ...timing.fast, easing: Easing.bezier(...ease.out) }),
withDelay(WASH_HOLD, withTiming(0, { ...timing.normal, easing: Easing.bezier(...ease.in) }))
);
if (!reduce) {
glyph.value = withSequence(withTiming(COMMIT_DIP, timing.instant), withSpring(1, spring.swap));
}
}
was.current = active;
}, [active, reduce, wash, glyph]);
const washStyle = useAnimatedStyle(() => ({ opacity: wash.value * WASH_PEAK }));
const glyphStyle = useAnimatedStyle(() => ({ transform: [{ scale: glyph.value }] }));
return (
<AnimatedPressable
ref={ref}
disabled={disabled}
onPress={() => {
if (disabled) return;
if (haptic) haptics[haptic]();
onPress?.();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
className={cn(
"h-11 min-w-11 items-center justify-center px-1",
disabled && "opacity-40",
className,
)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={active === undefined ? { disabled: !!disabled } : { disabled: !!disabled, selected: active }}
>
<Animated.View
pointerEvents="none"
className="absolute h-10 w-10 rounded-full bg-primary"
style={washStyle}
/>
<Animated.View style={glyphStyle}>
<Icon
name={icon}
size="md"
filled={!!active}
color={tone === "foreground" ? "foreground" : "primaryText"}
/>
</Animated.View>
</AnimatedPressable>
);
},
);
/** Large/inline title for children-first composition in the `leading`/`trailing` slots. */
export const AppBarTitle = forwardRef<React.ElementRef<typeof Text>, { children?: React.ReactNode; className?: string }>(
function AppBarTitle({ children, className }, ref) {
return (
<Text
ref={ref}
className={cn("text-[17px] font-semibold leading-tight text-foreground", className)}
numberOfLines={1}
accessibilityRole="header"
>
{children}
</Text>
);
},
);
export const AppBar = Object.assign(AppBarRoot, {
Back: AppBarBack,
Action: AppBarAction,
Title: AppBarTitle,
});
AppBar.displayName = "AppBar";
AppBarBack.displayName = "AppBar.Back";
AppBarAction.displayName = "AppBar.Action";
AppBarTitle.displayName = "AppBar.Title"; import { AppBar } from "@/components/ui/app-bar/app-bar";
export function Example() {
return (
<AppBar />
);
} Installation
Install the dependencies
npx expo install react-native-reanimated react-native-safe-area-contextAdd the components it builds on
Copy the source from the Code tab into your project
components/ui/app-bar/app-bar.tsxcomponents/ui/app-bar/index.tsIcons it renders
Update the import paths to match your project setup.
Usage
import { AppBarRoot, AppBarBack, AppBarAction, AppBarTitle, AppBar } from "@/components/ui/app-bar/app-bar"; <AppBar /> Also exported: AppBarRoot, AppBarBack, AppBarAction, AppBarTitle.
Examples
An issue-tracker detail screen: compact centred title with a status subtitle, accent back link, and trailing actions that change state.
import * as React from "react";
import { View } from "react-native";
import { AppBar } from "../app-bar";
import { Avatar } from "../../avatar/avatar";
import { Text } from "../../text/text";
/** AppBarActions — an issue-tracker detail screen: compact centred title
* with a status subtitle, accent back link, and trailing actions that
* change state. Watching commits on the bell (accent wash, held) and the
* subtitle swaps in place; done disables itself. */
export function AppBarActions() {
const [watching, setWatching] = React.useState(false);
const [done, setDone] = React.useState(false);
const rows: [string, React.ReactNode][] = [
[
"Status",
<View key="s" className="flex-row items-center gap-2">
<View className={`h-2.5 w-2.5 rounded-full ${done ? "bg-tint-green" : "bg-tint-amber"}`} />
<Text className="text-[15px]">{done ? "Done" : "In progress"}</Text>
</View>,
],
[
"Assignee",
<View key="a" className="flex-row items-center gap-2">
<Avatar size="sm" source={{ uri: "https://i.pravatar.cc/150?img=33" }} fallback="RL" alt="Rhea Lindqvist" />
<Text className="text-[15px]">Rhea Lindqvist</Text>
</View>,
],
["Cycle", <Text key="c" className="text-[15px]">Cycle 42 · ends Fri</Text>],
["Estimate", <Text key="e" className="text-[15px] tabular-nums">3 points</Text>],
];
return (
<View className="w-full">
<View className="w-full overflow-hidden rounded-3xl bg-background">
<AppBar
safeArea={false}
title="ENG-2481"
subtitle={done ? "Closed just now" : watching ? "Watching" : "Mobile · Backlog"}
showBack
backLabel="Backlog"
actions={[
{
icon: "bell",
active: watching,
accessibilityLabel: watching ? "Stop watching" : "Watch issue",
onPress: () => setWatching((v) => !v),
},
{
icon: "check-circle",
accessibilityLabel: done ? "Marked done" : "Mark done",
disabled: done,
onPress: () => setDone(true),
},
]}
/>
<View className="gap-5 px-5 pb-6 pt-5">
<View className="gap-2">
<Text className="text-2xl font-bold tracking-tight">
Offline drafts lose attachments after relaunch
</Text>
<Text className="text-[15px] leading-[22px] text-muted-foreground">
Photos added to a draft while offline are dropped if the app is closed before it syncs. Seen on phones and tablets since build 4.2.
</Text>
</View>
<View className="overflow-hidden rounded-2xl bg-card">
{rows.map(([label, value], i) => (
<View
key={label}
className={`min-h-12 flex-row items-center justify-between px-4 py-2 ${i > 0 ? "border-t border-border" : ""}`}
>
<Text className="text-[15px] text-muted-foreground">{label}</Text>
{value}
</View>
))}
</View>
</View>
</View>
</View>
);
} A hiking-trail detail screen.
import * as React from "react";
import { Image, View } from "react-native";
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedScrollHandler,
useSharedValue,
} from "react-native-reanimated";
import { Accent } from "@lumioui/core";
import { AppBar } from "../app-bar";
import { Text } from "../../text/text";
const PHOTO =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=1000&fit=crop&q=70&auto=format";
/** The trail name moves into the capsule once the hero heading has scrolled under it. */
const TITLE_AT = 200;
const STATS: [string, string][] = [
["Distance", "11.4 km"],
["Climb", "620 m"],
["Time", "4h 10m"],
];
/** AppBarFloating — a hiking-trail detail screen. The floating capsule is
* clear over the photo and sharpens as the page scrolls under it; the trail
* name swaps into it once the heading has gone. Saving the trail fills the
* heart and holds an accent wash on the button. */
export function AppBarFloating() {
const [saved, setSaved] = React.useState(false);
const [titled, setTitled] = React.useState(false);
const scrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y;
});
// Only the threshold crossing reaches React — not every scroll frame.
useAnimatedReaction(
() => scrollY.value > TITLE_AT,
(past, prev) => {
if (past !== prev) runOnJS(setTitled)(past);
}
);
return (
<Accent color="green" className="w-full">
<View className="relative h-[480px] w-full overflow-hidden rounded-3xl bg-background">
<Animated.ScrollView onScroll={onScroll} scrollEventThrottle={16} showsVerticalScrollIndicator={false}>
<Image
source={{ uri: PHOTO }}
style={{ width: "100%", height: 280 }}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
<View className="-mt-6 gap-5 rounded-t-3xl bg-background px-5 pb-10 pt-6">
<View className="gap-1">
<Text className="text-[13px] font-semibold uppercase tracking-wide text-primary-text">
Hard · Loop
</Text>
<Text className="text-3xl font-bold tracking-tight">Kestrel Ridge Loop</Text>
<Text className="text-[15px] text-muted-foreground">
{saved ? "Saved to Weekend hikes" : "Alder Valley Reserve · 4.8 from 312 walkers"}
</Text>
</View>
<View className="flex-row gap-3">
{STATS.map(([label, value]) => (
<View key={label} className="flex-1 gap-1 rounded-2xl bg-card px-4 py-3">
<Text className="text-[13px] text-muted-foreground">{label}</Text>
<Text className="text-lg font-semibold tabular-nums">{value}</Text>
</View>
))}
</View>
<Text className="text-[15px] leading-[22px] text-muted-foreground">
A steady climb through larch forest to an open ridge with views over both lakes. The
descent follows the old quarry track; poles help on the loose section after the
second stile.
</Text>
<Text className="text-[15px] leading-[22px] text-muted-foreground">
Water at the trailhead only. The ridge is exposed — turn back if the wind picks up
past the cairn.
</Text>
</View>
</Animated.ScrollView>
<AppBar
floating
safeArea={false}
scrollY={scrollY}
className="absolute left-0 right-0 top-0 pt-3"
showBack
backLabel=""
title={titled ? "Kestrel Ridge" : ""}
actions={[
{
icon: "heart",
active: saved,
accessibilityLabel: saved ? "Remove from saved" : "Save trail",
onPress: () => setSaved((v) => !v),
},
{ icon: "share", accessibilityLabel: "Share trail", onPress: () => {} },
]}
/>
</View>
</Accent>
);
} A photo album: large title over the grid it names.
import * as React from "react";
import { Pressable, View } from "react-native";
import { AppBar } from "../app-bar";
import { Icon } from "../../icon/icon";
import { Image } from "../../image/image";
import { Text } from "../../text/text";
const U = (id: string) => `https://images.unsplash.com/${id}?w=300&h=300&fit=crop&q=70&auto=format`;
const SHOTS = [
"photo-1506905925346-21bda4d32df4",
"photo-1470071459604-3b5ec3a7fe05",
"photo-1500530855697-b586d89ba3ee",
"photo-1500382017468-9049fed747ef",
"photo-1483412033650-1015ddeb83d1",
"photo-1519681393784-d120267933ba",
"photo-1488646953014-85cb44e25828",
"photo-1506126613408-eca07ce68773",
"photo-1446776811953-b23d57bd21aa",
];
/** AppBarLargeTitle — a photo album: large title over the grid it names.
* "Select" flips the bar into selection mode — tap tiles to pick them and
* the subtitle counts the picks. */
export function AppBarLargeTitle() {
const [selecting, setSelecting] = React.useState(false);
const [picked, setPicked] = React.useState<number[]>([]);
const toggle = (i: number) =>
setPicked((p) => (p.includes(i) ? p.filter((x) => x !== i) : [...p, i]));
const live = selecting && picked.length > 0;
return (
<View className="w-full overflow-hidden rounded-3xl bg-background">
<AppBar
size="large"
safeArea={false}
bordered={false}
title="Cliff Road, June"
subtitle={
selecting
? picked.length === 0
? "Select items"
: `${picked.length} selected`
: "128 photos · 6 videos"
}
showBack
backLabel="Albums"
trailing={
<Pressable
onPress={() => {
setSelecting((s) => !s);
setPicked([]);
}}
accessibilityRole="button"
accessibilityLabel={selecting ? "Cancel selection" : "Select photos"}
className="mr-2 h-8 min-w-11 items-center justify-center rounded-full bg-muted px-3"
hitSlop={6}
>
<Text className="text-[15px] font-semibold">{selecting ? "Cancel" : "Select"}</Text>
</Pressable>
}
/>
<View className="flex-row flex-wrap gap-0.5 pt-2">
{SHOTS.map((id, i) => {
const on = picked.includes(i);
return (
<Pressable
key={id}
onPress={() => (selecting ? toggle(i) : undefined)}
accessibilityRole={selecting ? "checkbox" : "image"}
accessibilityState={selecting ? { checked: on } : undefined}
accessibilityLabel={`Photo ${i + 1}`}
className="aspect-square w-[32.9%]"
>
<Image source={{ uri: U(id) }} className="h-full w-full" alt={`Photo ${i + 1}`} />
{selecting ? (
<View className="absolute bottom-2 right-2">
{on ? (
<View className="h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-primary">
<Icon name="check" size="sm" color="primaryForeground" strokeWidth={3} />
</View>
) : (
<View className="h-6 w-6 rounded-full border-2 border-white bg-black/20" />
)}
</View>
) : null}
</Pressable>
);
})}
</View>
<View className="flex-row items-center justify-between px-5 py-4">
<Icon name="share" size="md" color={live ? "foreground" : "mutedForeground"} />
<Text className="text-[13px] text-muted-foreground">Updated just now</Text>
<Icon name="trash" size="md" color={live ? "destructive" : "mutedForeground"} />
</View>
</View>
);
} API reference
AppBar
title string Bar title. "small" centers it inline; "large" renders a large title below the row.
—subtitle string Secondary line under the centered title (small mode only).
—size "small" | "large" "small" = classic inline bar; "large" = large-title header. Default "small".
—showBack boolean Show a back chevron + label on the leading edge.
—backLabel string Label next to the back chevron — conventionally the previous screen's title. Default "Back".
—onBack () => void — leading React.ReactNode Leading slot — overrides the back button when set.
—actions AppBarActionItem[] Trailing action icon buttons.
—trailing React.ReactNode Trailing slot — overrides actions when set.
safeArea boolean Pad for the status bar. Default true.
—bordered boolean Bottom hairline. Default true.
—translucent boolean Translucent bar: edge-to-edge glass material for content scrolling underneath, and the bar's buttons wear glass capsules — the bar is transparent and the controls carry the glass. Adjacent trailing actions union into one pill.
—floating boolean Floating capsule bar — the bar detaches into a rounded glass capsule hovering over the content (island bar). Implies translucent.
—haptic "light" | "selection" | false Haptic on button presses. Default "light".
—scrollY SharedValue<number> Scroll offset of the content under the bar. When passed, light follows scroll: the surface, its frost and the bottom hairline are clear at rest and fully on by 24pt (floating capsules sharpen instead). Drive it from useAnimatedScrollHandler — it is read on the UI thread.
className string — AppBarBack
label string Visible label next to the chevron. Default "Back".
"Back" tone AppBarTone Chevron + label tint. Default "primary"; glass bars pass "foreground".
"primary" onPress () => void — haptic "light" | "selection" | false "light" className string — accessibilityLabel string — AppBarAction
haptic "light" | "selection" | false "light" tone AppBarTone Icon tint. Default "primary"; glass bars pass "foreground".
"primary" className string —