npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@rnw-community/react-native-screen-chrome

v3.0.6

Published

Composable scroll-driven screen chrome for React Native and React Native Web

Readme

React Native Screen Chrome

Composable, safe-area-aware screen chrome for React Native and React Native Web. It paints collapsible titles, persistent navigation controls, progressive edge fades, and content insets around one scrollable, while all motion, scroll wiring, and collapse snapping are delegated to @rnw-community/react-native-collapsible-header and all visible content and product behavior stay consumer-owned.

npm version coverage

Installation

npm install @rnw-community/react-native-screen-chrome \
    @rnw-community/react-native-collapsible-header \
    expo react-native-reanimated react-native-safe-area-context

The native libraries and @rnw-community/react-native-collapsible-header are peer dependencies and must be installed by the host application: the application must resolve exactly one copy of the collapsible header, otherwise its scroll context has two identities and the chrome components fail to find their provider. Supported versions are react-native-reanimated >=4.1.0 <5 on react-native >=0.78; Reanimated 4 applications also install react-native-worklets and configure react-native-worklets/plugin.

The package is an Expo module: its native ScreenChromeProgressiveBlurView draws the edge-fade blur on iOS and Android, so the application needs expo (>=55 <59), the New Architecture, and a native rebuild (npx expo prebuild or pod install) after installing it. Existing bare React Native apps without Expo modules must first run npx install-expo-modules@latest or follow the manual Expo modules setup. Expo Go does not include the view. On iOS the view applies Core Animation's variableBlur filter, which UIKit does not expose publicly; it is reached by runtime lookup. If a future iOS removes it, the blur view hides rather than drawing a hard-edged uniform blur. On Android it eases a QmBlurView blur out through a gradient mask. The wash is a experimental_backgroundImage gradient. Web uses CSS backdrop-filter layers and never loads the native view.

Migrating to 3.0

3.0 replaces the optional blur stack with this package's required Expo native view. Remove blurMethod and blurTarget from EdgeFade and CollapsibleHeaderBackdrop. Custom renderBand / renderEdgeFadeBand callbacks no longer receive tint; render the supplied blur or defaultBand element instead.

Before (2.x):

<BlurTargetView ref={blurTarget}>{content}</BlurTargetView>
<EdgeFade position="bottom" blurMethod="dimezisBlurView" blurTarget={blurTarget} />

After (3.0):

<View>{content}</View>
<EdgeFade position="bottom" chromeHeight={48} />

chromeHeight is still accepted but no longer changes the look: the blur holds its full radius only over the outermost 8% of the band and eases in across the rest of it. Bands now start at the screen edge; the native default header backdrop height is 128 points instead of 220. intensityInputRange now fades a fixed-radius native blur through opacity, with the radius set by maxIntensity, rather than animating the radius.

Remove expo-blur, @react-native-masked-view/masked-view, expo-linear-gradient, and @sbaiahmed1/react-native-blur if nothing else in your app uses them. None is a peer or fallback in 3.0. Install the new required expo peer (>=55 <59); bare React Native apps without Expo modules first run npx install-expo-modules@latest or follow the manual setup. Build a new native binary after upgrading (npx expo run:ios / npx expo run:android, or your native build pipeline). An OTA update alone cannot install the view. Expo Go is unsupported; use a development or production build. A missing native module is an error. Web continues to use CSS backdrop-filter.

Complete example

import React from 'react';
import { Pressable, Text } from 'react-native';

import {
    CollapsibleHeader,
    CollapsibleHeaderBackdrop,
    CollapsibleHeaderSlot,
    CollapsibleHeaderTitleSlot,
    EdgeFade,
    ScreenChromeFrame,
    ScreenChromeProvider,
    ScreenChromeScrollView,
} from '@rnw-community/react-native-screen-chrome';

const SCREEN_CHROME_CONFIG = { snapToCollapse: true };

export const AccountsScreen = () => (
    <ScreenChromeProvider config={SCREEN_CHROME_CONFIG}>
        <ScreenChromeFrame>
            <ScreenChromeScrollView contentInsetTop={96} contentInsetBottom={48}>
                <Text>Consumer-owned content</Text>
            </ScreenChromeScrollView>
            <CollapsibleHeaderBackdrop />
            <EdgeFade position="bottom" />
            <CollapsibleHeader>
                <CollapsibleHeaderSlot>
                    <Pressable accessibilityRole="button">
                        <Text>Back</Text>
                    </Pressable>
                </CollapsibleHeaderSlot>
                <CollapsibleHeaderTitleSlot>
                    <Text>Accounts</Text>
                    <Text>Accounts</Text>
                </CollapsibleHeaderTitleSlot>
                <CollapsibleHeaderSlot>
                    <Pressable accessibilityRole="button">
                        <Text>Menu</Text>
                    </Pressable>
                </CollapsibleHeaderSlot>
            </CollapsibleHeader>
        </ScreenChromeFrame>
    </ScreenChromeProvider>
);

Structure and paint order

ScreenChromeProvider owns the merged and validated configuration plus the color scheme, and mounts a CollapsibleHeaderProvider that owns the scroll offset, scroll handler, animated scroll ref, and snap registry. ScreenChromeScrollView connects that wiring automatically; any other animated scrollable spreads useScreenChromeScrollProps (see Recipes), and a scrollable that keeps its own offset drives the chrome through the provider's scrollY prop instead. useScreenChrome returns { colorScheme, config, renderEdgeFadeBand?, externalScrollY? }.

Mount one ScreenChromeProvider per scrollable, inside each screen, and never once around a navigator: a provider owns exactly one scroll offset and one snap slot, so a shared provider makes the last-scrolled screen drive every header. Several chrome components within one screen share one provider by design.

Render content first, decorative EdgeFade and CollapsibleHeaderBackdrop layers second, and interactive CollapsibleHeader chrome last. Native blur then samples the content beneath it while controls remain above decorative layers. ScreenChromeScrollView adds safe-area padding and caller-provided content insets; explicit consumer padding in contentContainerStyle remains last and wins.

Styling and NativeWind

The package bundles no theme and parses no class names. Every rendered node takes plain style objects or arrays (including unistyles-sanitized objects), merged after the package defaults so the consumer value wins:

| Node | Style prop | Other props | | --------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------- | | ScreenChromeFrame | style | every ViewProps prop | | ScreenChromeScrollView | style, contentContainerStyle (see contentInsetMode) | every ScrollView prop except the scroll wiring | | CollapsibleHeader container | style | every ViewProps prop except pointerEvents | | CollapsibleHeader title layers | expandedContentContainerStyle, collapsedContentContainerStyle | — | | CollapsibleHeader control row | persistentContentContainerStyle (layer), persistentRowStyle (row inside the layer) | — | | CollapsibleHeaderSlot | style | every ViewProps prop except pointerEvents | | ScreenChromeHeader | style (container), rowStyle (row) | every ViewProps prop except pointerEvents | | EdgeFade, CollapsibleHeaderBackdrop | style | every ViewProps prop except the inert props below | | ScreenChromeFooter | style (container), contentStyle (measured content) | every ViewProps prop except pointerEvents |

CollapsibleHeaderTitleSlot is a grouping marker whose own node is never mounted — its two children become the title layers — so title layers are styled through expandedContentContainerStyle and collapsedContentContainerStyle. Package-owned props are applied after the spread on purpose: pointerEvents="box-none" on the header, slots and footer keeps empty chrome from swallowing touches, and pointerEvents="none" plus the hidden-accessibility props on edge fades keep decorative bands out of hit testing and screen readers.

NativeWind resolves className at the JSX call site, and this package ships precompiled output, so a className string forwarded into the package would reach a native view unresolved. Wrap the components you style with classes once at module scope instead — NativeWind v5 styled, or cssInterop with the same mapping on v4. NativeWind v5 has no global registration, so the package ships no side-effect className entry point; the wrapper is the supported path:

import { styled } from 'nativewind';

const ChromeScrollView = styled(ScreenChromeScrollView, {
    className: 'style',
    contentContainerClassName: 'contentContainerStyle',
});
const ChromeHeaderSlot = styled(CollapsibleHeaderSlot, { className: 'style' });

CollapsibleHeader

Composes the optional persistent leading/trailing controls and the expanded/collapsed title layers of its compound children into the generic @rnw-community/react-native-collapsible-header primitive, reading geometry and thresholds from useScreenChrome.

<CollapsibleHeader>
    <CollapsibleHeaderSlot>{/* leading control */}</CollapsibleHeaderSlot>
    <CollapsibleHeaderTitleSlot>
        {/* expanded title */}
        {/* collapsed title */}
    </CollapsibleHeaderTitleSlot>
    <CollapsibleHeaderSlot>{/* trailing control */}</CollapsibleHeaderSlot>
</CollapsibleHeader>

Both control slots are optional — a title-only header, or one with just a back button or just a trailing action, is the same component with fewer children:

<CollapsibleHeader>
    <CollapsibleHeaderTitleSlot>
        {/* expanded title */}
        {/* collapsed title */}
    </CollapsibleHeaderTitleSlot>
    <CollapsibleHeaderSlot>{/* trailing control */}</CollapsibleHeaderSlot>
</CollapsibleHeader>

A missing control is replaced by an empty, non-interactive placeholder in the persistent row, so the row keeps its space-between layout and a lone trailing control stays right-aligned instead of sliding to the leading edge.

The header container rests at safeAreaInsets.top + config.headerHeight and collapses to safeAreaInsets.top + config.collapsedHeaderHeight (which defaults to headerHeight, i.e. no height change) over the collapseStart–collapseEnd window. Both title layers and the persistent control row start at safeAreaInsets.top and fill the remaining header height, so config.headerHeight is always the usable expanded content height regardless of notch or dynamic-island depth, and the controls and collapsed title stay vertically centered in the shrinking band. The primitive positions those layers absolutely inside the animated header, so the offset is a layer top rather than container padding — padding leaves absolute layers resolving against the full container box and centers their content over the inset instead of below it. It is the same composition ScreenChromeScrollView applies to contentInsetTop, so passing contentInsetTop={config.headerHeight} clears the overlay header exactly at rest. Content insets and header metrics always use the expanded headerHeight: content must clear the header before any scroll, and the overlay header only shrinks once that content has scrolled under it.

<ScreenChromeProvider config={{ headerHeight: 156, collapsedHeaderHeight: 40 }}>{children}</ScreenChromeProvider>

Pass motion to override individual per-layer animation windows or transforms derived from config — any key you set wins, the rest stay config-driven. This covers app-specific motion (for example an earlier background fade start, or expanded-layer scale) without widening the config surface:

<CollapsibleHeader motion={{ backgroundOpacityStartProgress: 0.7, expandedScale: 0.9 }}>{/* slots */}</CollapsibleHeader>

Both title layers default to centered content behind a 72pt gutter that keeps the title clear of the leading and trailing controls. For an iOS-style leading large title, set expandedTitleAlignment="start" (ExpandedTitleAlignment): the expanded title stretches from the leading edge and is padded by the row padding plus the default 44pt slot size on the side of each non-empty slot, so it clears standard icon controls and uses the space of an empty slot. The collapsed title stays centered. The reserve is static, not measured: a control wider than 44pt (a text button, or a slot resized through style) needs a matching paddingStart/paddingEnd in expandedContentContainerStyle.

<CollapsibleHeader expandedTitleAlignment="start">{/* slots */}</CollapsibleHeader>;

const { rowPadding } = SCREEN_CHROME_HEADER_LAYOUT_METRICS;

<CollapsibleHeader expandedTitleAlignment="start" expandedContentContainerStyle={{ paddingStart: rowPadding + 72 }}>
    {/* a 72pt-wide "Cancel" leading slot */}
</CollapsibleHeader>;

expandedContentContainerStyle, collapsedContentContainerStyle and persistentContentContainerStyle are forwarded to the primitive and merged after those defaults, so a consumer style always wins. persistentRowStyle merges after the control row's own defaults (space-between, row padding), for example to change the row's horizontal padding. Derive custom offsets from SCREEN_CHROME_HEADER_LAYOUT_METRICS instead of copying the numbers.

Compound header contract

CollapsibleHeader requires exactly one CollapsibleHeaderTitleSlot direct child and accepts at most one CollapsibleHeaderSlot before it (the leading control) and at most one after it (the trailing control). The title slot must directly contain the expanded title followed by the collapsed title. null, false, and undefined children are ignored, so {canGoBack && <CollapsibleHeaderSlot>…</CollapsibleHeaderSlot>} works; fragments, host views, and unmarked wrapper components are rejected so slot discovery stays deterministic.

Slots are recognized by a stable string marker, screenChromeSlotKind (a CollapsibleHeaderSlotKind), that each slot component carries as a static property — 'control' for CollapsibleHeaderSlot, 'title' for CollapsibleHeaderTitleSlot — never by component identity. Two resolved copies of this package in one bundle (for example dist/module for one importer and dist/commonjs for another) produce two distinct function identities for the same slot; the marker still matches both, so such a bundle renders instead of throwing.

The shape above is a compound-component contract that TypeScript's type system cannot express: JSX children are ReactNode, so nothing in the type checker distinguishes a valid slot set from an arbitrary fragment tree at compile time. CollapsibleHeader therefore validates its children at render time and throws a TypeError naming the violated rule (unknown child type, missing title slot, duplicate slot, wrong title-layer count) instead of silently mounting a broken layout.

Slot discovery deliberately inspects children synchronously instead of having each slot register itself through context: registration would mount the slots, collect them in an effect, and only then render the header layers, so the first frame would paint an empty header.

CollapsibleHeaderSlot

Renders one persistent leading or trailing control slot in a collapsible header, mounted once above both title transition layers.

<CollapsibleHeaderSlot>
    <Pressable accessibilityRole="button">
        <Text>Back</Text>
    </Pressable>
</CollapsibleHeaderSlot>

The slot is pointerEvents="box-none", so an empty or narrow slot never swallows taps while its children stay pressable. It defaults to a 44pt minimum; style merges last to adjust size or spacing, so overriding minWidth/minHeight makes the rendered control responsible for its own touch target. Every other ViewProps prop (testID, collapsable, accessibility props, onLayout, …) is forwarded to the slot node.

CollapsibleHeaderTitleSlot

Groups the direct expanded and collapsed title layers of a compound collapsible header; title opacity and pointer-event handoff between them are delegated to @rnw-community/react-native-collapsible-header.

<CollapsibleHeaderTitleSlot>
    <Text>Accounts</Text>
    <Text>Accounts</Text>
</CollapsibleHeaderTitleSlot>

Inside CollapsibleHeader the slot itself is not mounted — its two children become the title layers — so style those layers through expandedContentContainerStyle and collapsedContentContainerStyle.

CollapsibleHeaderSlotKind

The screenChromeSlotKind marker value a compound header child carries: 'control' | 'title'. A wrapper component around a slot is only accepted when it carries the same static marker as the slot it renders:

const BackSlot = ({ onPress }: { readonly onPress: () => void }) => (
    <CollapsibleHeaderSlot>
        <Pressable accessibilityRole="button" onPress={onPress}>
            <Text>Back</Text>
        </Pressable>
    </CollapsibleHeaderSlot>
);
BackSlot.screenChromeSlotKind = 'control' satisfies CollapsibleHeaderSlotKind;

CollapsibleHeaderBackdrop

Renders a top EdgeFade sized to headerBackdropHeight and aligned with the configured title-collapse thresholds, so native blur samples the content scrolling beneath the header.

<CollapsibleHeaderBackdrop />

Every EdgeFade prop except position is forwarded, so testID, style, and renderBand reach the band; height, intensity and scrollAnimation can be overridden:

<CollapsibleHeaderBackdrop testID="header-backdrop" renderBand={renderSolidBand} />

CollapsibleHeaderBackdropPropsInterface

Omit<EdgeFadePropsInterface, 'position'>: height defaults to headerBackdropHeight, and the band remains visible with a static blur at maxBlurIntensity across header collapse unless an explicit scrollAnimation is supplied. An explicit intensity overrides that default. Its wash and blur follow the same ease-in ramp as every other band.

const backdropProps: CollapsibleHeaderBackdropPropsInterface = { testID: 'header-backdrop', height: 120 };

Edge fades

Every band starts at the screen edge and covers the safe-area inset plus its configured height. A band holds its full blur only over the outermost 8% of its height, then eases the blur in with a quadratic ramp (d², where d runs from 0 at the inner edge to 1 where the full blur starts), so content stays nearly sharp across most of the band and the blur only gathers near the screen edge, like the iOS system bars. iOS also varies the radius along the same ramp. The blur sits under a color wash that is fully as opaque at the screen edge as the configured color alpha allows (alpha multiplied by the maskStops alpha, whose defaults sample the same d² ramp) and eases to transparent inward. Bands ignore pointer events and accessibility traversal, and scroll animation changes opacity while the radii stay static. Native defaults use 150-point top and bottom bands, while web defaults use 76-pixel bands.

EdgeFade

Renders a decorative top or bottom blur band, scroll-animatable through scrollAnimation.

<EdgeFade position="bottom" height={96} scrollAnimation={{ opacityInputRange: [0, 80] }} />

Bands hold the full blur radius over the outermost 8% of the band and ease it in with the quadratic ramp across the rest of the band (masked variable blur on iOS, masked uniform blur on Android and web), so content blurs in gradually without a visible start line. Web uses the same plateau and ramp. The edge radius is max(8, intensity × 0.45) points, using scrollAnimation.maxIntensity while an intensityInputRange is set, which fades the native view in through opacity, so scrolling never rebuilds the blur.

On web a fixed-radius backdrop-filter blends into the original content through an eased mask across the entire ramp, under a separately masked wash. Its box extends two radii past the inner edge to keep the sampling boundary outside the visible mask. Only scrollAnimation.opacityInputRange animates, fading each visual rather than the container: an ancestor with opacity below 1 or a mask becomes the backdrop root and prevents sampling the page. The radius stays static so scrolling can animate opacity without re-rasterizing the blurred backdrop.

Custom band content

renderBand replaces the band content while the package keeps the band metrics, absolute positioning, safe-area offset, scroll-driven opacity, and the inert pointer/accessibility props on the container. The renderer receives the resolved visuals (EdgeFadeBandRenderPropsInterface), including the animated blur element and the complete defaultBand, so a renderer can wrap, compose, or conditionally fall back to the package output. Pass it per instance, or once on ScreenChromeProvider as renderEdgeFadeBand; the per-instance prop wins. Without either, the output is unchanged. Web honours the same contract: the blur layer and the wash move off the container into blur (the blur layer only) and defaultBand (the blur layer and wash) when a renderer is set, and the container keeps the scroll-driven opacity. In Chromium a container opacity below 1 is a backdrop root, so the web blur renders unblurred while a renderer band is mid-fade.

const renderSolidBand: EdgeFadeBandRenderer = ({ colorScheme }) => (
    <View style={{ flex: 1, backgroundColor: colorScheme === 'dark' ? '#000000' : '#ffffff' }} />
);

<EdgeFade position="bottom" renderBand={renderSolidBand} />;

EdgeFadePropsInterface

| Prop | Type | Description | | ----------------- | ---------------------------------- | ------------------------------------------------------------------------------------ | | position | EdgeFadePosition | Screen edge the band renders at. | | height | number | Band height; defaults to the provider config's fade height. | | chromeHeight | number | Deprecated and ignored; the full blur covers the outer 8% of every band. | | intensity | number | Static blur intensity; ignored while scrollAnimation drives it. | | scrollAnimation | EdgeFadeScrollAnimationInterface | Optional scroll-driven opacity and blur intensity ranges. | | renderBand | EdgeFadeBandRenderer | Custom band content; defaults to the provider's renderEdgeFadeBand, then built-in. |

EdgeFadeScrollAnimationInterface

Configures scroll-driven edge-fade opacity and blur intensity.

const scrollAnimation: EdgeFadeScrollAnimationInterface = {
    opacityInputRange: [0, 80],
    intensityInputRange: [0, 80],
    maxIntensity: 60,
};

EdgeFadePosition

Identifies the screen edge where a fade band is rendered: 'top' | 'bottom'.

EdgeFadeBandRenderer

(props: EdgeFadeBandRenderPropsInterface) => ReactNode — renders custom band content inside the package's positioned, scroll-animated, inert band container. Returning null renders an empty band.

const renderGlassBand: EdgeFadeBandRenderer = ({ defaultBand }) =>
    isLiquidGlassAvailable() ? <GlassView style={{ flex: 1 }} /> : defaultBand;

EdgeFadeBandRenderPropsInterface

| Prop | Type | Description | | --------------- | -------------------------------- | ---------------------------------------------------------------------- | | position | EdgeFadePosition | Screen edge the band renders at. | | colorScheme | ScreenChromeColorScheme | Active provider color scheme. | | height | number | Resolved band height, safe-area inset included. | | washColors | readonly [string, string] | Wash gradient colors from top to bottom, solid at the screen edge. | | maskColors | readonly [string, string, ...] | Mask colors from the config's maskStops for this position, sorted. | | maskLocations | readonly [number, number, ...] | Mask locations matching maskColors. | | blur | ReactNode | The package's blur element with its static or scroll-driven intensity. | | defaultBand | ReactNode | The package's complete default band (blur under the eased wash). |

getEdgeFadeBandMetrics

Resolves the geometry an edge-fade band uses — height (fade height plus the edge inset) and a 0 top or bottom offset, so the band starts at the screen edge and covers the safe-area inset — for custom bands positioned outside EdgeFade in a full-screen parent such as ScreenChromeFrame.

const insets = useSafeAreaInsets();
const { config } = useScreenChrome();
const metrics = getEdgeFadeBandMetrics('bottom', undefined, config, insets);

<View pointerEvents="none" style={[{ position: 'absolute', left: 0, right: 0 }, metrics]} />;

EdgeFadeBandMetricsInterface

The { height, top?, bottom? } geometry returned by getEdgeFadeBandMetrics; only the offset matching the position is set.

ScreenChromeFrame

Provides the relative full-screen layout root for content, fades, and header chrome.

<ScreenChromeFrame>{children}</ScreenChromeFrame>

Sheets, modals, and nested navigators

The chrome never reads the native header, the window size, or the status bar: every inset comes from useSafeAreaInsets() of the nearest SafeAreaProvider, and every layer is positioned inside ScreenChromeFrame. A screen therefore needs no special handling inside Expo Router screenLayout wrappers, nested stacks, or headerShown: false navigators — only the provider that feeds it the insets must describe the screen's own frame.

An iOS formSheet or pageSheet (and a native modal in general) starts below the status bar, while a provider at the app root still reports the full-screen top inset. Mount a SafeAreaProvider at the root of the sheet screen, as react-native-safe-area-context recommends for modals, so it measures the sheet itself; the header then gets a 0 top inset instead of a phantom notch gap:

export const EditSheet = () => (
    <SafeAreaProvider>
        <ScreenChromeProvider>
            <ScreenChromeFrame>{/* content, fades, header */}</ScreenChromeFrame>
        </ScreenChromeProvider>
    </SafeAreaProvider>
);

Detents only change the frame height: the bottom fade and ScreenChromeFooter stay anchored to the frame's bottom edge, and the native sheet clips the chrome to its corner radius. Space for a grabber is app-owned — pass ScreenChromeHeader topInset, or a larger config.headerHeight for CollapsibleHeader.

ScreenChromeHeader

Renders a static, non-collapsible header row with the same safe-area and paint-order contract as the collapsible one: absolute at the top, zIndex: 3, box-none, sized to insets.top + topInset + headerHeight so getScreenChromeHeaderMetrics predicts its footprint exactly. topInset adds app-owned padding beyond the safe area for screens that nest under an already-padded container. Requires a ScreenChromeProvider ancestor. style and every other ViewProps prop except pointerEvents go to the container; rowStyle merges after the row defaults (space-between, row padding), so rowStyle={{ paddingHorizontal }} replaces the default 16pt row padding; it never overrides the configured headerHeight, so the metrics stay exact.

<ScreenChromeHeader testID="header" topInset={10} style={styles.headerBackground} rowStyle={{ paddingHorizontal: 8 }}>
    <Button onPress={goBack} title="Back" />
    <Text>{title}</Text>
</ScreenChromeHeader>

inFlow lays the header out in the normal flow instead of floating it over the content: the container is position: 'relative' and the row takes headerHeight as a minHeight, so a multi-line title or a bottom row grows the header and pushes the content down instead of being clipped. The header still pads the safe area, so give the scrollable below it includeTopSafeArea={false} and no header content inset:

<ScreenChromeFrame>
    <ScreenChromeHeader inFlow>
        <Text>{title}</Text>
    </ScreenChromeHeader>
    <ScreenChromeScrollView includeTopSafeArea={false}>{rows}</ScreenChromeScrollView>
</ScreenChromeFrame>

ScreenChromeProvider

Provides validated configuration and color scheme to screen chrome components around one scrollable.

<ScreenChromeProvider colorScheme="dark" config={{ snapToCollapse: true }}>
    {children}
</ScreenChromeProvider>

colorScheme defaults to 'light'; pass 'system' to follow the device appearance — see ScreenChromeColorSchemePreference.

The provider and its chrome survive react-freeze (enableFreeze()) and <Activity mode="hidden">: the scroll offset, the collapsed header state, edge-fade scroll subscriptions, and snap registration all persist across a hide and show cycle. <Activity> itself requires React 19.2 or newer; the package's react >=18 peer floor only covers react-freeze.

syncNativeScrollOffset is forwarded to the underlying CollapsibleHeaderProvider: it mirrors the native scroll offset into the shared scroll value so navigator offset restores without a scroll event keep the header in sync. It is opt-in because it requires the chrome scroll ref to resolve to a real host scrollable — see the collapsible-header readme for the full constraint.

renderEdgeFadeBand sets the app-wide band renderer every EdgeFade and CollapsibleHeaderBackdrop inside the provider uses unless it passes its own renderBand — see EdgeFade custom band content:

<ScreenChromeProvider renderEdgeFadeBand={renderSolidBand}>{children}</ScreenChromeProvider>

Pass a stable config reference — a module-scope constant, or one owned by state — rather than the inline literal above. React Compiler memoizes the merge, validation, and context value on the identity of that prop, so a fresh literal on every parent render re-merges, re-validates, and re-broadcasts the context to every chrome consumer:

const SCREEN_CHROME_CONFIG = { snapToCollapse: true };

export const AccountsScreen = () => <ScreenChromeProvider config={SCREEN_CHROME_CONFIG}>{children}</ScreenChromeProvider>;

External scroll offset

scrollY hands the chrome an app-owned Reanimated SharedValue<number> instead of the provider-owned offset. Every scroll-driven piece — CollapsibleHeader, EdgeFade scroll animations, useScrollInterpolation, and useScrollFadeStyle — then reads it, so any scrollable that can write its offset into a shared value drives the chrome without attaching the provider's onScroll or ref:

export const FeedScreen = () => {
    const scrollY = useSharedValue(0);
    const onScroll = useAnimatedScrollHandler(event => {
        scrollY.set(event.contentOffset.y);
    });

    return (
        <ScreenChromeProvider scrollY={scrollY}>
            <ScreenChromeFrame>
                <AnimatedLegendList data={posts} renderItem={renderPost} onScroll={onScroll} scrollEventThrottle={16} />
                <CollapsibleHeader>
                    <CollapsibleHeaderTitleSlot>
                        <Text>Feed</Text>
                        <Text>Feed</Text>
                    </CollapsibleHeaderTitleSlot>
                </CollapsibleHeader>
            </ScreenChromeFrame>
        </ScreenChromeProvider>
    );
};

Write the offset from a worklet (useAnimatedScrollHandler, or a library's own shared value) rather than a JS-thread onScroll, and use .get()/.set() rather than .value so the recipe stays React Compiler safe. Snapping scrolls the provider-owned scrollable through its ref, which an external source never attaches, so passing scrollY together with snapToCollapse: true throws during provider render — the same single-scroll-source rule the collapsible-header primitive enforces for its own scrollY prop. Pass a stable shared value: the provider exposes it through context, so a new one per render re-broadcasts the context to every chrome consumer.

ScreenChromeScrollView

Connects an animated scroll view to the collapsible-header scroll wiring and safe-area content padding.

<ScreenChromeScrollView contentInsetTop={96} contentInsetBottom={48}>
    <Text>Consumer-owned content</Text>
</ScreenChromeScrollView>

Scroll is package-owned: onScroll and scrollEventThrottle come from the provider (useCollapsibleHeaderScroll and config.scrollEventThrottle) and are omitted from the props, so passing them is a type error instead of a silently discarded value. A consumer ref is accepted and fans out through mergeRefs alongside the chrome scroll ref, so imperative APIs like scrollTo and keyboard-aware wrappers keep working without detaching the header wiring.

contentInsetMode selects how the computed safe-area+chrome padding meets a consumer contentContainerStyle: 'replace' (the default, unchanged) lets consumer padding win, while 'additive' stacks the computed insets on top of consumer paddingTop/paddingBottom via addScrollContentInset and never injects horizontal padding:

<ScreenChromeScrollView contentInsetMode="additive" contentInsetTop={96} contentContainerStyle={{ paddingTop: 8 }} />

includeTopSafeArea={false} drops the top safe-area inset from the content padding and indicator insets for a scrollable laid out below an in-flow header that already pads the notch:

<ScreenChromeScrollView includeTopSafeArea={false} />

The scroll view also sets scrollIndicatorInsets to the same safe-area+chrome top and bottom insets (with automaticallyAdjustsScrollIndicatorInsets={false}), so the iOS scroll indicator never runs under the header or the bottom inset; pass either prop to override it.

Change the event rate through <ScreenChromeProvider config={{ scrollEventThrottle: 8 }}>. The provider's onScroll is one processed Reanimated handler that also carries drag and snap events, so it is attached whole rather than wrapped — read the scroll position from the shared offset through useScreenChromeScrollY instead:

const scrollY = useScreenChromeScrollY();

useAnimatedReaction(
    () => scrollY.get(),
    offsetY => {
        runOnJS(onOffsetChange)(offsetY);
    }
);

Every other ScrollView prop passes through untouched, including the scroll callbacks the package does not own (onMomentumScrollEnd, onContentSizeChange, …) and contentContainerStyle — consumer padding is applied after the safe-area padding and wins.

ScreenChromeScrollView is a thin consumer of useScreenChromeScrollProps; use the hook directly to wire any other scrollable.

useScreenChromeScrollProps

Composes the scroll handler, merged ref, throttle, content padding and indicator insets any scrollable needs for chrome — the same bag ScreenChromeScrollView spreads, so a list gets identical wiring and insets. Accepts the ScreenChromeScrollPropsOptionsInterface options and returns ScreenChromeScrollPropsInterface; spread it last so the package-owned onScroll/scrollEventThrottle are not overridden.

const TransactionList = () => {
    const scrollProps = useScreenChromeScrollProps({ contentInsetTop: 96 });

    return <Animated.FlatList data={transactions} keyExtractor={keyExtractor} renderItem={renderItem} {...scrollProps} />;
};

FlashList takes the same bag once wrapped at module scope; pass a consumer ref through the options so it merges with the chrome ref instead of replacing it:

const AnimatedFlashList = Animated.createAnimatedComponent(FlashList<Transaction>);

const TransactionList = () => {
    const listRef = useRef<FlashListRef<Transaction>>(null);
    const scrollProps = useScreenChromeScrollProps({ contentInsetTop: 96, ref: listRef });

    return <AnimatedFlashList data={transactions} keyExtractor={keyExtractor} renderItem={renderItem} {...scrollProps} />;
};

The returned ref attaches to component instances and imperative handles alike; for LegendList pass it as refScrollView (const { ref, ...scrollProps } = useScreenChromeScrollProps()). The worklet onScroll only stays cheap when rows do not re-render per scroll frame, so keep renderItem rendering memoized item components without inline object or arrow literals, and give the list a stable module-scope keyExtractor.

ScreenChromeScrollPropsOptionsInterface

Configures useScreenChromeScrollProps: contentInsetTop/contentInsetBottom (default 0), contentInsetMode (default 'replace', see ScrollContentInsetMode), a consumer contentContainerStyle, includeTopSafeArea (default true; false under an in-flow header), and a consumer ref merged with the chrome scroll ref.

ScreenChromeScrollPropsInterface

The { ref, onScroll, scrollEventThrottle, contentContainerStyle, scrollIndicatorInsets, automaticallyAdjustsScrollIndicatorInsets } bag returned by useScreenChromeScrollProps. ref is a memoized merged callback ref, stable until the consumer ref changes.

Recipes

Every recipe spreads useScreenChromeScrollProps last, so the list gets the worklet onScroll, the chrome ref, the provider throttle, and the same content and indicator insets as ScreenChromeScrollView. They mirror the collapsible-header recipes; FlashList and LegendList are not dependencies of this package — the ref contract is structural.

The worklet handler only stays cheap when rows do not re-render per scroll frame: render memoized item components, keep inline object and arrow literals out of renderItem, use a stable module-scope keyExtractor, and give heterogeneous lists item types (getItemType on FlashList and LegendList). React Compiler covers the memoization half only in applications that enable it.

FlatList

Animated.FlatList ships with Reanimated:

const keyExtractor = (item: Transaction): string => item.id;
const renderItem = ({ item }: ListRenderItemInfo<Transaction>) => <TransactionRow transaction={item} />;

const TransactionList = () => {
    const scrollProps = useScreenChromeScrollProps({ contentInsetTop: 96 });

    return <Animated.FlatList data={transactions} keyExtractor={keyExtractor} renderItem={renderItem} {...scrollProps} />;
};

SectionList

Reanimated has no animated SectionList; wrap it once at module scope so the component identity is stable. This is the path off a JS-thread onScroll for a sectioned feed:

const AnimatedSectionList = Animated.createAnimatedComponent(SectionList<Transaction>);

const TransactionSections = () => {
    const scrollProps = useScreenChromeScrollProps({ contentInsetTop: 96 });

    return <AnimatedSectionList sections={sections} keyExtractor={keyExtractor} renderItem={renderItem} {...scrollProps} />;
};

FlashList

Wrap FlashList the same way and pass a consumer ref through the options so it merges with the chrome ref; the imperative handle is a supported ref target:

const AnimatedFlashList = Animated.createAnimatedComponent(FlashList<Transaction>);

const TransactionList = () => {
    const listRef = useRef<FlashListRef<Transaction>>(null);
    const scrollProps = useScreenChromeScrollProps({ contentInsetTop: 96, ref: listRef });

    return (
        <AnimatedFlashList
            data={transactions}
            keyExtractor={keyExtractor}
            renderItem={renderItem}
            getItemType={getItemType}
            {...scrollProps}
        />
    );
};

LegendList

@legendapp/list publishes its own Reanimated build. Hand the chrome ref to refScrollView — the inner animated ScrollView that scrollTo drives — and keep the list's own ref free for imperative calls:

import { AnimatedLegendList } from '@legendapp/list/reanimated';

const TransactionList = () => {
    const { ref, ...scrollProps } = useScreenChromeScrollProps({ contentInsetTop: 96 });

    return (
        <AnimatedLegendList
            refScrollView={ref}
            data={transactions}
            keyExtractor={keyExtractor}
            renderItem={renderItem}
            {...scrollProps}
        />
    );
};

Scrollables that own their offset

When a scrollable cannot take the provider's onScroll — a wrapper that installs its own handler, or a library that already exposes its offset as a shared value — pass that offset to the provider's scrollY instead. To keep the content and indicator insets, spread useScreenChromeScrollProps before the scrollable's own onScroll: props later in the JSX win, so the scrollable's handler must come after the bag or the provider handler replaces it and the external offset is never written. Snapping stays off with an external offset.

<AnimatedLegendList data={posts} renderItem={renderPost} {...useScreenChromeScrollProps()} onScroll={onScroll} />

Bottom chrome

Bottom chrome is a footer pinned over the scroll content plus an inset contract that lets the content clear it: ScreenChromeFooter measures itself and publishes the height into the nearest BottomChromeInsetProvider, and useBottomChromeInset reads it back where the scrollable is rendered.

const AccountsContent = () => {
    const { chromeHeight } = useBottomChromeInset();

    return (
        <>
            <ScreenChromeScrollView contentInsetBottom={chromeHeight}>{rows}</ScreenChromeScrollView>
            <ScreenChromeFooter>
                <TotalRow />
            </ScreenChromeFooter>
        </>
    );
};

export const AccountsScreen = () => (
    <ScreenChromeProvider>
        <BottomChromeInsetProvider>
            <ScreenChromeFrame>
                <AccountsContent />
            </ScreenChromeFrame>
        </BottomChromeInsetProvider>
    </ScreenChromeProvider>
);

The package ships no tab bar and no hide-on-scroll bottom bar. Expo Router's NativeTabs renders the platform tab bar — the Liquid Glass bar with native minimize-on-scroll on iOS 26 — and owns its insets, so a JavaScript re-implementation would only lag behind the platform. Use ScreenChromeFooter for screen-level action and summary bars; a custom bottom bar the app already owns publishes its height through useBottomChromeHeightPublisher.

BottomChromeInsetProvider

Owns the measured bottom chrome height that ScreenChromeFooter publishes and useBottomChromeInset reads. It is independent of ScreenChromeProvider, so it can wrap one screen or sit above a navigator when the same bottom chrome persists across every screen in it. Prefer one provider per screen: a stack keeps a covered screen's footer mounted, and a navigator-wide provider would keep publishing that footer's height to the screen pushed on top. Each footer registers its own height, and the provider publishes the tallest one: footers are bottom-anchored and overlap, so the tallest one is what the content has to clear, and unmounting one footer leaves the others' heights in place.

<BottomChromeInsetProvider>{screen}</BottomChromeInsetProvider>

useBottomChromeInset

Returns a BottomChromeInsetInterface: chromeHeight is the measured footer height without the safe area, which is exactly what ScreenChromeScrollView's contentInsetBottom expects because the scroll view adds the safe-area bottom itself; inset adds the safe-area bottom for surfaces that do not, such as a FlatList contentContainerStyle or an absolutely positioned button. Without a provider or a mounted footer chromeHeight is 0, so the hook never throws. A reserve with a floor composes from it directly:

const { chromeHeight } = useBottomChromeInset();

<ScreenChromeScrollView contentInsetBottom={Math.max(132, chromeHeight + 88)} />;

The value changes only when the footer's measured height changes — never per scroll frame and never when the footer is moved by a transform — so pairing it with the scroll view relayouts content only on real footer resizes.

useBottomChromeHeightPublisher

Publishes one bottom chrome height into the nearest BottomChromeInsetProvider — the publisher ScreenChromeFooter uses internally — for bottom chrome the app renders itself, such as a custom floating tab bar. Spread the returned BottomChromeHeightPublisherInterface onto the node to measure it on mount and on every layout:

const TabBar = () => (
    <View collapsable={false} style={styles.tabBar} {...useBottomChromeHeightPublisher()}>
        {tabs}
    </View>
);

Pass a declared height (see BottomChromeHeightPublisherOptionsInterface) to reserve a fixed height instead: layout events are then ignored, so the reservation survives the bar being hidden by a transform, opacity, or display: 'none'. Call the hook from a component that stays mounted, such as the tab layout, to keep the reservation while the bar itself unmounts, so useBottomChromeInset consumers never jump:

const TabLayout = () => {
    useBottomChromeHeightPublisher({ height: TAB_BAR_HEIGHT });

    return <Tabs>{isTabBarVisible ? <TabBar /> : null}</Tabs>;
};

The height excludes the safe-area bottom, like every published height. Unmounting the publisher withdraws only its own entry, and the provider keeps publishing the tallest entry.

BottomChromeHeightPublisherOptionsInterface

| Option | Type | Description | | -------- | -------- | --------------------------------------------------------------------------------------------- | | height | number | Declared height without the safe area, reserved while mounted instead of the measured height. | | offset | number | Added to the published height for chrome stacked above other bottom chrome. Default 0. |

const options: BottomChromeHeightPublisherOptionsInterface = { height: 68 };

useBottomChromeHeightPublisher(options);

BottomChromeHeightPublisherInterface

The { ref, onLayout } view props returned by useBottomChromeHeightPublisher: ref is measured synchronously on mount and onLayout republishes every resize. Keep collapsable={false} on the node so Android does not flatten it away.

const { ref, onLayout }: BottomChromeHeightPublisherInterface = useBottomChromeHeightPublisher();

<View collapsable={false} onLayout={onLayout} ref={ref} />;

BottomChromeInsetInterface

| Field | Type | Description | | -------------- | -------- | ------------------------------------------------------------------------------------------- | | chromeHeight | number | Measured bottom chrome height without the safe area; pass to contentInsetBottom. | | inset | number | chromeHeight plus the safe-area bottom inset, for surfaces that do not add the safe area. |

const { inset }: BottomChromeInsetInterface = useBottomChromeInset();

<FlatList contentContainerStyle={{ paddingBottom: inset }} data={rows} renderItem={renderRow} />;

ScreenChromeFooter

Pins consumer footer content to the bottom edge over a bottom EdgeFade: an absolute, box-none container at zIndex: 3 padded by the safe-area bottom, with the fade band painted behind a content node. The content node is measured synchronously on mount and again on every onLayout, and the height is published to the nearest BottomChromeInsetProvider (a no-op without one); unmounting the footer withdraws only its own height. The fade reads the screen chrome config, so the footer requires a ScreenChromeProvider ancestor like every other chrome layer.

<ScreenChromeFooter contentStyle={{ alignSelf: 'center', maxWidth: 600, width: '100%' }} edgeFadeProps={{ height: 96 }}>
    <Button onPress={onSave} title="Save" />
</ScreenChromeFooter>

A testID lands on the container and ${testID}-content on the measured content node; every other View prop is forwarded to the container.

Stacked footer

To stack the footer above other bottom chrome, such as a tab bar, pass that chrome's footprint as bottomOffset, and pass includeBottomSafeArea={false} when the chrome below already pads the safe area. The footer publishes bottomOffset plus its content height, minus the safe area it no longer pads, so useBottomChromeInset clears both layers. Pass the lower chrome's own height (for example the height declared through useBottomChromeHeightPublisher), not useBottomChromeInset(): that value already includes this footer's height.

const insets = useSafeAreaInsets();

<ScreenChromeFooter bottomOffset={TAB_BAR_HEIGHT + insets.bottom} includeBottomSafeArea={false}>
    <SearchInput />
</ScreenChromeFooter>;

Keyboard-sticky footer

Keyboard tracking belongs to react-native-keyboard-controller, which the package deliberately does not depend on. Wrap the footer in its KeyboardStickyView, anchored where the footer would sit, and offset the opened state by the safe-area bottom the keyboard already covers:

const insets = useSafeAreaInsets();

<KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }} style={styles.footerAnchor}>
    <ScreenChromeFooter style={{ position: 'relative' }}>
        <MessageComposer />
    </ScreenChromeFooter>
</KeyboardStickyView>;
// styles.footerAnchor: { position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3 }

KeyboardStickyView moves the footer with a transform, which never fires onLayout, so the published inset keeps the footer's resting height while the keyboard is open; a keyboard-aware scrollable (KeyboardAwareScrollView) owns the keyboard's own clearance. A composer that grows while typing re-measures and republishes on its next layout.

To keep the fade band static while only the input follows the keyboard, put the KeyboardStickyView inside the footer instead. The opened offset is the footer's distance from the bottom edge, which for a stacked footer is bottomOffset:

const footprint = TAB_BAR_HEIGHT + insets.bottom;

<ScreenChromeFooter bottomOffset={footprint} includeBottomSafeArea={false}>
    <KeyboardStickyView offset={{ closed: 0, opened: footprint }}>
        <SearchInput />
    </KeyboardStickyView>
</ScreenChromeFooter>;

ScreenChromeFooterPropsInterface

| Prop | Type | Description | | ----------------------- | ------------------------------------------ | ------------------------------------------------------------------ | | children | ReactNode | Consumer-owned footer content, rendered above the fade band. | | contentStyle | StyleProp<ViewStyle> | Style of the measured content node: max-width caps and padding. | | edgeFadeProps | Omit<EdgeFadePropsInterface, 'position'> | Props forwarded to the bottom EdgeFade behind the content. | | bottomOffset | number | Distance from the bottom edge for a stacked footer. Default 0. | | includeBottomSafeArea | boolean | Pads the safe-area bottom; false when stacked. Default true. | | ...ViewProps | ViewProps | Forwarded to the container, except children and pointerEvents. |

const footerProps: ScreenChromeFooterPropsInterface = {
    children: <TotalRow />,
    contentStyle: { alignSelf: 'center', maxWidth: 600, width: '100%' },
    edgeFadeProps: { height: 96 },
    testID: 'totals-footer',
};

<ScreenChromeFooter {...footerProps} />;

ScreenChromeContext

The React context that carries the resolved screen chrome value to package components and hooks; consumers read it through useScreenChrome rather than useContext(ScreenChromeContext) directly.

useScreenChrome

Reads the nearest ScreenChromeContext value and throws when no ScreenChromeProvider is mounted.

const { colorScheme, config } = useScreenChrome();

useScreenChromeScrollY

Returns the SharedValue<number> scroll offset that drives the chrome: the provider's external scrollY when one is passed, otherwise the provider-owned offset. Every scroll-driven hook in the package reads it, so custom animations stay in sync with the header and fades regardless of the scroll source. Requires a ScreenChromeProvider ancestor.

const scrollY = useScreenChromeScrollY();

useAnimatedReaction(
    () => scrollY.get() > 200,
    isPastHero => {
        runOnJS(setShowJumpToTop)(isPastHero);
    }
);

useScrollInterpolation

Derives a clamped value from the chrome scroll offset — the raw driver, never a pre-baked style. Map the scroll range onto a normalized [0, 1] progress once and feed it to as many interpolations as needed, so translate, scale and opacity share one subscription:

const HERO_SCROLL_RANGE = [0, 120] as const;
const PROGRESS_RANGE = [0, 1] as const;

const progress = useScrollInterpolation(HERO_SCROLL_RANGE, PROGRESS_RANGE);
const heroStyle = useAnimatedStyle(() => ({
    opacity: 1 - progress.get(),
    transform: [{ translateY: -40 * progress.get() }, { scale: 1 - 0.1 * progress.get() }],
}));

Hoist inputRange and outputRange to module-scope constants as above: the derived-value worklet captures them, so inline array literals hand it fresh arrays on every render. Requires a ScreenChromeProvider ancestor and follows its external scrollY when set.

useScrollFadeStyle

Opacity sugar over useScrollInterpolation: a clamped { opacity } animated style mapped from the chrome scroll offset. Requires a ScreenChromeProvider ancestor.

const style = useScrollFadeStyle([0, 80], [1, 0]);

getScreenChromeHeaderMetrics

Computes the rendered header footprint from a header height, the device top inset, and an optional extra header top inset, matching the height the CollapsibleHeader and ScreenChromeHeader containers render. Pure and React-independent, so it works in non-component code and tests. Use the returned number as the scroll content's top inset so content starts below the header.

const headerTotalHeight = getScreenChromeHeaderMetrics(config.headerHeight, insets.top, topInset);

useScreenChromeHeaderMetrics

Returns getScreenChromeHeaderMetrics for the live provider config and device insets (no extra top inset — the collapsible container owns none), so screens offset their content from the rendered header without re-merging SCREEN_CHROME_DEFAULT_CONFIG. It always reports the expanded headerHeight footprint, never collapsedHeaderHeight: that is the space content must clear at rest. Requires a ScreenChromeProvider ancestor.

const headerTotalHeight = useScreenChromeHeaderMetrics();

ScreenChromeColorScheme

Selects the light or dark chrome palette for edge fades and backdrop blur tinting: 'light' | 'dark'. It is the resolved scheme every chrome consumer reads from useScreenChrome() and the band renderer receives — never 'system'.

const { colorScheme } = useScreenChrome();

ScreenChromeColorSchemePreference

The ScreenChromeProvider colorScheme input: a fixed ScreenChromeColorScheme, or 'system' to follow the device appearance. 'system' is resolved during provider render from React Native's useColorScheme() ('dark' selects the dark palette, anything else — 'light', 'unspecified', null — the light one) and never stored in state, so an appearance change re-renders the provider with the new palette and the context value stays 'light' | 'dark'. A fixed value ignores the device appearance entirely.

<ScreenChromeProvider colorScheme="system">{children}</ScreenChromeProvider>

ScreenChromeColorSetInterface

Defines the wash colors used by a chrome color scheme. solid is the color at the screen edge, wash is the color blended toward the inner edge, and each stop's opacity is the configured color alpha multiplied by the maskStops alpha, so a translucent color leaves the blurred content visible at the edge.

const lightColors: ScreenChromeColorSetInterface = { solid: 'rgba(255,255,255,0.42)', wash: 'rgba(255,255,255,0.08)' };

ScreenChromeConfigInterface

Configures screen chrome geometry, colors, fade masks, scroll throttling, and collapse thresholds. See Configuration and snapping for the threshold ordering rule and SCREEN_CHROME_DEFAULT_CONFIG for the full default shape.

| Field | Description | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | headerHeight | Expanded header content height below the safe area; used by CollapsibleHeader at rest, ScreenChromeHeader, and the header metrics. | | collapsedHeaderHeight | Optional collapsed CollapsibleHeader content height below the safe area; must be positive and not greater than headerHeight. Omitted by default, which keeps the collapsed height equal to headerHeight. |

ScreenChromeConfigOverridesInterface

Overrides selected screen chrome defaults while preserving nested color schemes and mask-stop records; the shape ScreenChromeProvider's config prop accepts.

<ScreenChromeProvider config={{ headerHeight: 72, colors: { dark: { solid: 'black' } } }}>{children}</ScreenChromeProvider>

ScreenChromeContextValueInterface

The { colorScheme, config, renderEdgeFadeBand?, externalScrollY? } shape returned by useScreenChrome; externalScrollY is the provider's scrollY prop when one is passed and absent otherwise. Read the offset that actually drives the chrome through useScreenChromeScrollY instead.

ScreenChromeMaskStopInterface

Defines one color stop in an edge-fade mask.

const stop: ScreenChromeMaskStopInterface = { color: 'rgba(0,0,0,0.99)' };

Configuration and snapping

Provider overrides deep-merge light/dark colors and top/bottom mask stops. Geometry, blur intensity, throttle, mask positions, colors, and transition ordering are validated; an optional collapsedHeaderHeight must be a positive finite number not greater than headerHeight. The required threshold order is collapseStart <= smallTitleStart <= largeTitleEnd <= collapseEnd with a non-zero collapse interval.

Thresholds are mapped to normalized collapse progress and handed to the generic header as its motion config, so smallTitleStart and largeTitleEnd keep their meaning while the transition itself is owned upstream. The pointer-event and accessibility switch is deliberately left unset so the primitive derives it from the mapped cross-fade midpoint, which keeps the interactive, accessibility-exposed title the one that is actually visible for every threshold pair.

snapToCollapse is forwarded to the generic header's snap prop, which snaps the scrollable toward the nearest endpoint once a released scroll holds still for three frames — momentum events are never relied on, because a worklet-only scroll handler never receives them on Android. Snapping therefore requires a mounted CollapsibleHeader: the header registers the snap geometry with the provider, so a screen that enables snapToCollapse without rendering CollapsibleHeader scrolls freely. With reduced motion enabled the snap jumps to its endpoint instead of animating. Snapping also requires the provider-owned scroll: combining snapToCollapse with an external scrollY throws.

<img src="https://raw.githubusercontent.com/rnw-community/rnw-community/master/packages/react-native-screen-chrome/docs/snap-off.gif" alt="Collapsible header with snapToCollapse off resting mid-collapse where the dra