/**
* Glass — the material iOS draws its own floating controls in.
*
* A control that floats over content needs to read as *over* it, and a flat
* fill cannot say that: it either hides what is behind it or disappears into
* it. The system material does both jobs at once — it refracts what is behind,
* lifts its own edge, and stays legible over anything.
*
* ```tsx
*
* Over whatever is behind it.
*
* ```
*
* ## Where it is real, and where it is not
*
* The material exists on iOS 26 and above. Below that, on Android, on web, and
* for anyone who has switched Reduce Transparency on, this draws
* `fallbackClassName` instead — a solid token surface. That is deliberate
* rather than a gap: a hand-drawn approximation of a system material is a
* near-miss on the one platform that has the real thing, and on Android it is
* an iOS look pasted onto a platform that never asked for it.
*
* So `Glass` is not a promise that the screen will be glass. It is a promise
* that the container will be drawn correctly either way, and callers should
* pick a `fallbackClassName` that stands on its own.
*
* ## The material is a layer, not the box
*
* The native view is rendered as a non-interactive layer filling this
* container, with the content above it, for the same reason `Scrim` layers a
* blur: it keeps every layout class, every token and every touch target on an
* ordinary `View` that behaves the way the rest of the library does.
*
* `radius` is a number rather than a class because the material has to round
* its own corners — clipping a square one to a rounded parent throws away the
* lit edge that makes it read as glass.
*
* `interactive` is the one exception to the layer: the platform only animates
* the glass under a touch it can see, so with it on the material is the box
* and the children are hosted inside it, in normal flow. Reach for it when
* the glass *is* the button.
*
* ## Do not fade it
*
* Setting `opacity` to `0` on the material or on anything above it stops it
* rendering at all, and it does not come back when the opacity does. Move it,
* or unmount it; never animate it out.
*/
import { forwardRef, type ComponentType, type ReactNode } from 'react';
import { Platform, StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native';
import { useThemeMode } from '../theme/use-theme';
import { cn } from '../utils/cn';
import { useReduceTransparency } from './scrim';
/** How the material treats what is behind it. */
export type GlassVariant = 'regular' | 'clear';
/**
* Corner radius in points — one number for all four, or a top and a bottom.
*
* The two-sided form is for a surface with an edge that is not a real edge: a
* sheet docked to the bottom of the screen rounds its top and leaves its
* bottom square, because the screen edge is where it ends.
*/
export type GlassRadius = number | { top?: number; bottom?: number };
function shapeOf(radius: GlassRadius | undefined) {
if (radius === undefined) return null;
if (typeof radius === 'number') return { borderRadius: radius };
return {
borderTopLeftRadius: radius.top ?? 0,
borderTopRightRadius: radius.top ?? 0,
borderBottomLeftRadius: radius.bottom ?? 0,
borderBottomRightRadius: radius.bottom ?? 0,
};
}
interface GlassViewProps extends ViewProps {
glassEffectStyle?: GlassVariant | 'none';
tintColor?: string;
isInteractive?: boolean;
colorScheme?: 'auto' | 'light' | 'dark';
style?: StyleProp;
children?: ReactNode;
}
interface GlassContainerViewProps extends ViewProps {
spacing?: number;
ref?: React.Ref;
}
interface GlassModule {
GlassView: ComponentType;
GlassContainer: ComponentType;
}
/**
* `expo-glass-effect`, or null when the material cannot be drawn here.
*
* Resolved once at module load, behind three gates that all have to pass. The
* package is optional, so the require can fail; the API is missing from some
* iOS 26 builds, and reaching for it there crashes; and the design itself is
* only present when the app was compiled for it. Asking all three once is
* cheaper than a try/catch on every render, and there is no answer that can
* change while the process is alive.
*/
const glassModule: GlassModule | null = (() => {
if (Platform.OS !== 'ios') return null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('expo-glass-effect');
if (typeof mod?.isGlassEffectAPIAvailable === 'function' && !mod.isGlassEffectAPIAvailable()) {
return null;
}
if (typeof mod?.isLiquidGlassAvailable === 'function' && !mod.isLiquidGlassAvailable()) {
return null;
}
return mod?.GlassView ? (mod as GlassModule) : null;
} catch {
return null;
}
})();
const GlassView = glassModule?.GlassView ?? null;
const GlassContainerView = glassModule?.GlassContainer ?? null;
/**
* True when the real material can be drawn — for a caller that wants to know
* before it commits to a look. It says nothing about Reduce Transparency, which
* is a live preference and belongs to the hook.
*/
export const hasGlass = GlassView !== null;
/**
* Whether the material will actually be drawn right now: the API is present
* *and* the user has not switched Reduce Transparency on.
*
* For a component that changes shape around the material — dropping a fill, a
* border or a shadow the glass replaces — so that it makes the same decision
* `Glass` makes and never strips the fill while leaving nothing behind it.
*/
export function useGlassMaterial(): boolean {
const reduceTransparency = useReduceTransparency();
// Not knowing yet counts as "do not draw it": the material arriving a frame
// late is invisible, and one flashing at somebody who opted out is not.
return hasGlass && reduceTransparency === false;
}
export interface GlassProps extends ViewProps {
/**
* How much of what is behind shows through. `regular` is the everyday
* material; `clear` is thinner, for something over its own artwork.
*/
variant?: GlassVariant;
/** Tints the material. Takes a colour, not a token name. */
tint?: string;
/**
* Corner radius in points. The material rounds itself to this, so give it
* the same shape the container is drawn with — clipping a square material
* to a rounded parent throws away the lit edge that makes it read as glass.
*/
radius?: GlassRadius;
/**
* Let the material answer touch the way the platform's own controls do:
* it brightens and swells under the finger and the highlight follows it.
*
* For a material that *is* a button. The platform only tracks touches that
* land inside the glass view, so with this on the content is hosted inside
* the material rather than above it — a pressable written as a child still
* gets its press, and the glass reacts to the same touch.
*/
interactive?: boolean;
/** Applied only when the material cannot be drawn. Give it a real surface. */
fallbackClassName?: string;
className?: string;
}
export function Glass({
variant = 'regular',
tint,
radius,
interactive = false,
fallbackClassName = 'bg-card',
className,
children,
style,
...props
}: GlassProps) {
/*
* Which appearance the material is drawn in, from the app's theme rather
* than the phone's.
*
* The material's own default follows the system, so an app running a dark
* theme on a phone set to light draws light glass over dark content — and a
* theme changed at runtime leaves it where it was, because the system
* appearance never moved.
*/
const { mode } = useThemeMode();
const material = useGlassMaterial();
const shape = shapeOf(radius);
/*
* Interactive, the material is the box rather than a layer in it. The
* platform only tracks a touch that lands inside the glass view, so the
* content has to be hosted in it — and hosted in normal flow, so that a
* box sized by its content still is. A layer pinned to the box's edges
* could not size it, and a button as wide as its label would collapse to
* its minimum. The classes go on a view inside, because the native view is
* not a styled one; only the positional style stays on the outside.
*/
if (material && GlassView && interactive) {
return (
{children}
);
}
return (
{material && GlassView ? (
) : null}
{children}
);
}
Glass.displayName = 'Glass';
export interface GlassContainerProps extends ViewProps {
/**
* How close two pieces of glass have to be before they merge, in points.
* Inside it their edges flow into one another; a piece moving past
* another blends with it and pulls free as it leaves.
*/
spacing?: number;
className?: string;
}
/**
* Lets the glass inside it merge.
*
* On its own each piece of the material is a separate object with its own
* lit edge. Inside a container, pieces within `spacing` of each other flow
* together — which is what makes a button that opens into other buttons look
* like one thing dividing rather than several things arriving. A plain view
* wherever the material is not drawn, so it can be written unconditionally.
*/
export const GlassContainer = forwardRef(
({ spacing, className, style, children, ...props }, ref) => {
const material = useGlassMaterial();
if (material && GlassContainerView) {
// The native container is not a styled view, so the classes go on a
// view inside it and only the positional style stays on the outside.
return (
{children}
);
}
return (
{children}
);
}
);
GlassContainer.displayName = 'GlassContainer';