@esaart/glass-bottom-surface
v0.1.1
Published
Reusable SwiftUI glass bottom surface for Expo apps.
Downloads
335
Maintainers
Readme
Glass Bottom Surface
Reusable iOS SwiftUI bottom surface for Expo apps. It renders one native view that can morph between:
- tab bar
- contextual toolbar
- status / toast capsule
- optional accessory row above the bar
The package is intentionally product-agnostic. Keep routing, analytics, stores, permissions, generation state, and action handling outside this package.
For a full app-level architecture similar to Esaart's unified bottom surface,
see docs/app-orchestration.md inside the published package.
Requirements
- Expo SDK 56+
@expo/ui- iOS custom build / dev client
- iOS 18+ deployment target
The native view uses iOS 26 Liquid Glass APIs when available and falls back to material backgrounds on older iOS versions.
Install
Install the package, then rebuild the native app:
npm install @esaart/glass-bottom-surface
npx pod-install iosBecause this is a native Expo module, the first install and every Swift change requires a new dev client/TestFlight/App Store build. JS-only usage changes can ship over the air after the native module is already present in the installed binary.
Test Without Publishing
From this repository:
npm pack --workspace=@esaart/glass-bottom-surfaceIn another Expo app:
npm install /path/to/esaart-glass-bottom-surface-0.1.1.tgz
npx pod-install iosWorkspace Usage
In a monorepo, add the package to the app:
{
"dependencies": {
"@esaart/glass-bottom-surface": "*"
}
}If Metro does not resolve workspace packages automatically, add an alias:
const path = require("path");
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, "../..");
const packagesPath = path.join(monorepoRoot, "packages");
config.watchFolders = [...(config.watchFolders || []), packagesPath];
config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
"@esaart/glass-bottom-surface": path.join(packagesPath, "glass-bottom-surface"),
};Basic Tabs
GlassBottomSurface is controlled from React. You own the selected index and
route switching.
import * as React from "react";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GlassBottomSurface } from "@esaart/glass-bottom-surface";
export function AppBottomBar() {
const insets = useSafeAreaInsets();
const [selection, setSelection] = React.useState(0);
return (
<View
pointerEvents="box-none"
style={[
StyleSheet.absoluteFill,
{ justifyContent: "flex-end", paddingBottom: insets.bottom },
]}
>
<GlassBottomSurface
style={{ height: 78 }}
icons={["house.fill", "square.stack.3d.up.fill", "bubble.left.fill"]}
labels={["Home", "Projects", "Chat"]}
selection={selection}
mainActionIcon="magnifyingglass"
mainActionShape="circle"
mainActionTint="#FF7A1A"
onSelectionChange={setSelection}
onTabPress={(index) => {
setSelection(index);
// Navigate to the selected route here.
}}
onMainAction={() => {
// Open search, compose, create, etc.
}}
/>
</View>
);
}Toolbar Mode
Set isMorphed and pass toolbarItems. The same native surface morphs from
tabs into toolbar controls.
import {
GlassBottomSurface,
type GlassToolbarItem,
} from "@esaart/glass-bottom-surface";
const flowToolbarItems: GlassToolbarItem[] = [
{ type: "button", id: "lock", icon: "lock.open", label: "Lock" },
{ type: "button", id: "assistant", icon: "sparkles", label: "Assistant" },
{ type: "spacer" },
{
type: "button",
id: "undo",
icon: "arrow.uturn.backward",
label: "Undo",
disabled: true,
},
{ type: "button", id: "redo", icon: "arrow.uturn.forward", label: "Redo" },
{ type: "button", id: "layout", icon: "square.grid.2x2", label: "Layout" },
{ type: "button", id: "add", icon: "plus", label: "Add" },
{ type: "main", label: "Run" },
];
<GlassBottomSurface
style={{ height: 78 }}
icons={["house.fill", "square.stack.3d.up.fill", "bubble.left.fill"]}
labels={["Home", "Projects", "Chat"]}
selection={0}
isMorphed
flexibility="fixed"
toolbarItems={flowToolbarItems}
mainActionIcon="play.fill"
mainActionShape="circle"
mainActionTint="#FF7A1A"
onToolbarAction={(id) => {
switch (id) {
case "lock":
break;
case "assistant":
break;
case "undo":
break;
case "redo":
break;
case "layout":
break;
case "add":
break;
}
}}
onMainAction={() => {
// Run primary action.
}}
/>;Flexible Toolbar Layout
Use flexibility="flexible" with { type: "flex" } to push groups apart.
This is useful for selection mode or toolbars with leading and trailing groups.
const selectionToolbarItems: GlassToolbarItem[] = [
{ type: "button", id: "close", icon: "xmark", label: "Close" },
{ type: "button", id: "count", icon: "checkmark.circle", title: "3" },
{ type: "flex" },
{ type: "button", id: "share", icon: "square.and.arrow.up", label: "Share" },
{ type: "button", id: "delete", icon: "trash", label: "Delete" },
];
<GlassBottomSurface
style={{ height: 78 }}
icons={["house.fill"]}
selection={0}
isMorphed
flexibility="flexible"
toolbarItems={selectionToolbarItems}
mainActionIcon="trash.fill"
mainActionTint="#FF453A"
onToolbarAction={(id) => {
// Handle selection toolbar action.
}}
/>;Toolbar Item Types
| Type | Purpose |
| --- | --- |
| button | Icon button. Emits onToolbarAction(id). |
| menu | Icon button with native menu actions. Emits selected action id. |
| main | Primary circular/capsule action. Uses onMainAction. |
| view | Native variant picker slot. Uses toolbarVariantCount. |
| spacer | Starts a separate visual glass group. |
| flex | Inserts flexible space between groups. |
Buttons
{ type: "button", id: "duplicate", icon: "doc.on.doc", label: "Duplicate" }label is semantic metadata for your JS side. It is not shown on the native
button. Use title only when you intentionally want visible text inside the
toolbar item.
Menus
const items: GlassToolbarItem[] = [
{
type: "menu",
id: "more",
icon: "ellipsis",
label: "More",
actions: [
{ id: "download", icon: "arrow.down", title: "Download" },
{ id: "delete", icon: "trash", title: "Delete" },
],
},
];Variant Picker
const items: GlassToolbarItem[] = [
{ type: "view", kind: "variants", id: "view-mode" },
{ type: "main", label: "Run" },
];
<GlassBottomSurface
icons={["house.fill"]}
selection={0}
isMorphed
toolbarItems={items}
toolbarVariantCount={3}
toolbarSelectedVariant={1}
onToolbarVariantSelect={(index) => {
// Switch native variant index.
}}
/>;Status / Toast Mode
Set mode-style status props and isMorphed. The package does not own timers;
your app decides when to show and hide the status state.
<GlassBottomSurface
style={{ height: 78 }}
icons={["house.fill"]}
selection={0}
isMorphed
statusTitle="Saved"
statusMessage="Project changes synced"
statusIcon="checkmark.circle.fill"
statusTint="#34C759"
/>;For long text, the native view keeps the message constrained inside the surface. Prefer short titles and one short sentence for status messages.
Accessory Row
Use an accessory when you need persistent content above the bottom bar, such as mini player state, auth state, or long-running flow state.
<GlassBottomSurface
style={{ height: 144 }}
icons={["house.fill", "bubble.left.fill"]}
labels={["Home", "Chat"]}
selection={0}
accessoryKind="flowRun"
accessoryTitle="Generating"
accessorySubtitle="Running 4 nodes"
accessoryIcon="arrow.triangle.2.circlepath"
accessoryTint="#FF7A1A"
accessoryProgress={0.35}
accessoryShowsProgress
accessoryPrimaryActionIcon="xmark"
accessorySecondaryActionIcon="chevron.up"
onAccessoryAction={(id) => {
// Native ids are emitted by the accessory controls.
}}
/>;When an accessory is visible, give the native view enough height for both the accessory and the bottom bar. In this project the app-level wrapper uses:
const dockHeight = 78;
const accessoryHeight = 58;
const accessoryGap = 8;
const totalHeight = dockHeight + accessoryHeight + accessoryGap;Props Reference
Core
| Prop | Type | Description |
| --- | --- | --- |
| icons | string[] | SF Symbol names for tabs. |
| labels | string[] | Optional tab labels. |
| selection | number | Controlled selected tab index. |
| isMorphed | boolean | Morphs from tabs into toolbar/status layouts. |
| flexibility | "fixed" | "flexible" | Layout behavior for toolbar groups. |
| style | StyleProp<ViewStyle> | React Native style for the host view. Set height here. |
Main / Side Actions
| Prop | Type | Description |
| --- | --- | --- |
| mainActionIcon | string | SF Symbol for the primary action. |
| mainActionShape | "capsule" | "circle" | Primary action shape. |
| mainActionTint | string | Primary tint color. |
| mainActionForeground | string | Primary icon/text color. |
| leadingActionIcon | string | Optional leading action icon. |
| trailingActionIcon | string | Optional trailing action icon. |
Toolbar
| Prop | Type | Description |
| --- | --- | --- |
| toolbarItems | GlassToolbarItem[] | Preferred high-level toolbar config. |
| toolbarVariantCount | number | Number of native variant options. |
| toolbarSelectedVariant | number | Selected native variant index. |
Low-level toolbarItemIds, toolbarItemKinds, and related array props are
exported for advanced callers, but most apps should use toolbarItems.
Status
| Prop | Type | Description |
| --- | --- | --- |
| statusTitle | string | Status title. |
| statusMessage | string | Status detail text. |
| statusIcon | string | SF Symbol for status. |
| statusTint | string | Status accent color. |
Accessory
| Prop | Type | Description |
| --- | --- | --- |
| accessoryKind | "none" | string | Non-"none" value shows accessory. |
| accessoryTitle | string | Accessory title. |
| accessorySubtitle | string | Accessory subtitle. |
| accessoryIcon | string | SF Symbol for accessory. |
| accessoryTint | string | Accessory accent color. |
| accessoryThumbnailUrl | string | Optional thumbnail image URL. |
| accessoryProgress | number | Progress value from 0 to 1. |
| accessoryShowsProgress | boolean | Shows progress line. |
| accessoryPrimaryActionIcon | string | Primary accessory action icon. |
| accessorySecondaryActionIcon | string | Secondary accessory action icon. |
Events
| Event | Payload |
| --- | --- |
| onSelectionChange | (selection: number) => void |
| onTabPress | (selection: number) => void |
| onMainAction | () => void |
| onLeadingAction | () => void |
| onTrailingAction | () => void |
| onToolbarAction | (id: string) => void |
| onToolbarVariantSelect | (index: number) => void |
| onAccessoryAction | (id: string) => void |
Recommended App Wrapper
For a production app, wrap the package in your own app-level controller. The package should stay generic; your wrapper should know about routes, auth, media state, current flow, selection mode, toast timers, and analytics.
export function BottomSurfaceDock({ state }: { state: BottomSurfaceState }) {
const insets = useSafeAreaInsets();
const hasAccessory = state.accessoryKind !== "none";
const height = hasAccessory ? 144 : 78;
return (
<View
pointerEvents="box-none"
style={{
position: "absolute",
left: 0,
right: 0,
bottom: insets.bottom,
height,
}}
>
<GlassBottomSurface
style={{ height }}
icons={state.tabs.map((tab) => tab.icon)}
labels={state.tabs.map((tab) => tab.label)}
selection={state.selection}
isMorphed={state.mode !== "tabs"}
toolbarItems={state.toolbarItems}
accessoryKind={state.accessoryKind}
accessoryTitle={state.accessoryTitle}
accessorySubtitle={state.accessorySubtitle}
/>
</View>
);
}Troubleshooting
The view is blank
The native module is not in the installed binary. Rebuild the dev client/custom build after installing the package:
npx pod-install ios
npx expo run:iosRed screen: SwiftUI view mounted inside a standard UIView
Wrap the native SwiftUI component with the package wrapper. The package already
uses Host from @expo/ui/swift-ui; do not call requireNativeView directly
from app code unless you also provide the correct SwiftUI host.
CocoaPods cannot find the pod
Check that expo-module.config.json is next to package.json, then run:
npm install
npx pod-install iosIn a monorepo, make sure the app depends on the package and Metro/CocoaPods can resolve the workspace package.
Toolbar text appears when only an icon should show
Use label for semantic metadata and title only for intentionally visible
text.
{ type: "button", id: "issue", icon: "exclamationmark.triangle", label: "Issue" }Touches are blocked behind the surface
Put your absolute-positioned wrapper on pointerEvents="box-none" and let the
native surface receive touches only within its own frame.
