Components
Nav bar
The app's two bars: the bottom tab bar with a notch for the centre action, and the top bar with the location pill, the logo and the date stepper.
Preview
Variants
Pick a variant with the controls. The preview and both code blocks follow.
Variant
Active tab
Centre action
Variant.tsx
<AppNavBar />Implementation
The React tab is this site's own component, the same source that draws the preview, styled by the CSS below it. The Flutter tab is the Dart a screen builds it with, from the app's tokens.
Usage
<AppNavBar /><AppNavBar variant="bottom" active="forecast" action={false} /><AppNavBar variant="top" />app-nav-bar.tsx · barPath
/** The bar's outline: rounded corners (--radius-f21) and the notch cut for the centre action, as NavBarClipper draws it. */function barPath(w: number): string { const c = w / 2; const r = F.f21; return `M${r} 0 H${c - NOTCH_HALF} C${c - F.f34} 0 ${c - F.f34} ${NOTCH_DEPTH} ${c} ${NOTCH_DEPTH} C${c + F.f34} ${NOTCH_DEPTH} ${c + F.f34} 0 ${c + NOTCH_HALF} 0 H${w - r} A${r} ${r} 0 0 1 ${w} ${r} V${BAR_H - r} A${r} ${r} 0 0 1 ${w - r} ${BAR_H} H${r} A${r} ${r} 0 0 1 0 ${BAR_H - r} V${r} A${r} ${r} 0 0 1 ${r} 0 Z`;}app-nav-bar.tsx · AppNavBar
/** * The app's two navigation bars. "bottom" is the tab bar with a notch for the centre action: four tabs, the active * one lit with a glow under its label, and the map action in the notch. "top" is the header: the location pill, the * logo, and the date stepper. */export function AppNavBar({ variant = "bottom", active: initial = "home", action = true,}: { variant?: "bottom" | "top"; active?: NavTab; /** Show the centre action in the notch (bottom bar). */ action?: boolean;}) { const [active, setActive] = useState<NavTab>(initial); const [ref, w] = useElementWidth<HTMLDivElement>(377); if (variant === "top") { return ( <div className="app-ui app-navbar is-top"> <button type="button" className="app-ui app-button is-glass is-sm"> <Glyph name="pin" /> <span className="app-navbar-loc">Current Location</span> </button> {/* eslint-disable-next-line @next/next/no-img-element */} <img className="app-navbar-logo" src="/astr-icon.png" alt="Astr" /> <div className="app-navbar-date"> <button type="button" className="app-ui app-tile is-sm" aria-label="Previous day"><Glyph name="chevron-left" /></button> <button type="button" className="app-ui app-button is-glass is-sm">Oct 10</button> <button type="button" className="app-ui app-tile is-sm" aria-label="Next day"><Glyph name="chevron-right" /></button> </div> </div> ); } return ( <div ref={ref} className="app-ui app-navbar is-bottom" style={{ height: BAR_H }}> <svg viewBox={`0 0 ${w} ${BAR_H}`} height={BAR_H} aria-hidden="true"> <defs> <linearGradient id="nav-sheen" x1="0" x2="1" y1="0" y2="1"> <stop offset="0" stopColor={stop("space-grey-50", 5)} /> <stop offset="1" stopColor={stop("space-grey-50", 8)} /> </linearGradient> <linearGradient id="nav-shine" gradientUnits="userSpaceOnUse" x1="0" x2="0" y1="0" y2={NOTCH_DEPTH}> <stop offset="0" stopColor={stop("deep-space-200", 10)} /> <stop offset="0.5" stopColor={stop("deep-space-200", 1)} /> <stop offset="1" stopColor={stop("deep-space-200", 10)} /> </linearGradient> </defs> <path d={barPath(w)} fill={stop("space-grey-950", 1)} /> <path d={barPath(w)} fill="url(#nav-sheen)" stroke={stop("space-grey-50", 5)} /> <path d={notchPath(w)} fill="none" stroke="url(#nav-shine)" strokeWidth={F.f2} strokeLinecap="round" /> </svg> <nav aria-label="Primary" className="app-navbar-items"> {TABS.slice(0, 2).map((t) => ( <NavItem key={t.id} tab={t} active={active === t.id} onSelect={setActive} /> ))} <span className="app-navbar-gap" aria-hidden="true" /> {TABS.slice(2).map((t) => ( <NavItem key={t.id} tab={t} active={active === t.id} onSelect={setActive} /> ))} </nav> {action && ( <button type="button" className="app-ui app-tile is-round app-navbar-action" aria-label="Sky map"> <Glyph name="map" /> </button> )} </div> );}app-nav-bar.tsx · NavItem
function NavItem({ tab, active, onSelect }: { tab: { id: NavTab; label: string; glyph: GlyphName }; active: boolean; onSelect: (t: NavTab) => void }) { return ( <div className="app-navbar-tab" data-active={active || undefined} onClick={() => onSelect(tab.id)}> <button type="button" className="app-ui app-tile is-sm" aria-label={tab.label} aria-current={active ? "page" : undefined} aria-pressed={active}> <Glyph name={tab.glyph} /> </button> <span className="app-micro" aria-hidden="true">{tab.label}</span> </div> );}app-ui.css
.app-navbar { position: relative; width: min(100%, var(--container-f377)); margin-inline: auto; }.app-navbar.is-bottom > svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.app-navbar-items { position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; padding: 0 var(--spacing-f21); }.app-navbar-gap { flex: 0 0 var(--spacing-f89); }.app-tile.is-round { border-radius: var(--radius-full); }.app-navbar-tab { display: grid; justify-items: center; gap: var(--spacing-f3); cursor: pointer; }.app-navbar-tab[data-active] .app-micro,.app-navbar-tab:has(.app-tile:active) .app-micro { color: var(--c-text); text-shadow: 0 var(--spacing-f1) var(--spacing-f13) var(--c-accent), 0 0 var(--spacing-f1) color-mix(in srgb, var(--c-text) var(--mag-3), transparent); }.app-navbar-action { position: absolute; left: 50%; top: calc(var(--spacing-f55) / -2); transform: translateX(-50%); }.app-navbar.is-top { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-f8); padding: var(--spacing-f8) var(--spacing-f13); border-radius: var(--radius-f21); border: var(--spacing-f1) solid var(--hair); background: var(--sheen), var(--fill); box-shadow: var(--highlight); box-sizing: border-box; }.app-navbar-date { display: flex; align-items: center; gap: var(--spacing-f5); }Usage
// ScaffoldWithNavBar owns both bars. Tabs: Home 0, Objects 1, Forecast 2, Settings 3.Scaffold(bottomNavigationBar: Stack(/* NavBarClipper and four _NavBarItem */),floatingActionButton: FloatingActionButton(/* sky map */),)scaffold_with_nav_bar.dart · NavBarClipper
class NavBarClipper extends CustomClipper<Path> { @override Path getClip(Size size) { final Path path = Path(); final double center = size.width / 2; // Start from top-left path.moveTo(0, 0); // Line to the start of the notch path.lineTo(center - 50, 0); // The Notch Curve (Same as NotchShinePainter) path.cubicTo( center - 35, 0, // Control point 1 center - 35, 42, // Control point 2 center, 42 // End point (bottom center) ); path.cubicTo( center + 35, 42, // Control point 1 center + 35, 0, // Control point 2 center + 50, 0 // End point ); // Line to top-right path.lineTo(size.width, 0); // Line to bottom-right path.lineTo(size.width, size.height); // Line to bottom-left path.lineTo(0, size.height); path.close(); return path; } @override bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;}scaffold_with_nav_bar.dart · NotchShinePainter
class NotchShinePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final Paint paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2.0 ..strokeCap = StrokeCap.round; // Gradient to create the "shine" effect // Fades out at the ends (top) and bottom, concentrating the shine in the middle final Shader shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: <Color>[ AppColors.accent.withValues(alpha: 0), AppColors.accent.withValues(alpha: 0.8), AppColors.accent.withValues(alpha: 0), ], stops: const <double>[0, 0.5, 1], ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); paint.shader = shader; final Path path = Path(); final double center = size.width / 2; // We draw a curve that follows the notch shape. // Starting from the top edge (y=0) and curving down. // Left side of the notch path.moveTo(center - 50, 0); path.cubicTo( center - 35, 0, // Control point 1 (flat start) center - 35, 42, // Control point 2 (curve down) center, 42 // End point (bottom center of notch) ); // Right side of the notch path.cubicTo( center + 35, 42, // Control point 1 (curve up) center + 35, 0, // Control point 2 (flat end) center + 50, 0 // End point ); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false;}scaffold_with_nav_bar.dart · _NavBarItem
_NavBarItem( icon: Ionicons.home_outline, activeIcon: Ionicons.home, label: 'Home', isSelected: navigationShell.currentIndex == 0, onTap: () => _onTap(context, 0), ), const SizedBox(width: 20), _NavBarItem( icon: Ionicons.planet_outline, activeIcon: Ionicons.planet, label: 'Objects', isSelected: navigationShell.currentIndex == 1, onTap: () => _onTap(context, 1), ), const Spacer(), const SizedBox(width: 48), // FAB notch gap const Spacer(), _NavBarItem( icon: Ionicons.calendar_outline, activeIcon: Ionicons.calendar, label: 'Forecast', isSelected: navigationShell.currentIndex == 2, onTap: () => _onTap(context, 2), ), const SizedBox(width: 20), _NavBarItem( icon: Ionicons.settings_outline, activeIcon: Ionicons.settings, label: 'Settings', isSelected: navigationShell.currentIndex == 3, onTap: () => _onTap(context, 3), ), const SizedBox(width: 32), ], ), ), ), ), ), // Shine Effect IgnorePointer( child: CustomPaint( size: const Size(100, 40), // Approximate size of the notch area painter: NotchShinePainter(), ), ), ], ), ); } Widget _buildLoadingOverlay(bool isLoading) { if (!isLoading) return const SizedBox.shrink(); return Positioned.fill( child: ColoredBox( color: Colors.black.withValues(alpha: 0.5), child: const Center( child: CosmicLoader(), ), ), ); } void _onTap(BuildContext context, int index) { navigationShell.goBranch( index, initialLocation: index == navigationShell.currentIndex, ); }}class NavBarClipper extends CustomClipper<Path> { @override Path getClip(Size size) { final Path path = Path(); final double center = size.width / 2; // Start from top-left path.moveTo(0, 0); // Line to the start of the notch path.lineTo(center - 50, 0); // The Notch Curve (Same as NotchShinePainter) path.cubicTo( center - 35, 0, // Control point 1 center - 35, 42, // Control point 2 center, 42 // End point (bottom center) ); path.cubicTo( center + 35, 42, // Control point 1 center + 35, 0, // Control point 2 center + 50, 0 // End point ); // Line to top-right path.lineTo(size.width, 0); // Line to bottom-right path.lineTo(size.width, size.height); // Line to bottom-left path.lineTo(0, size.height); path.close(); return path; } @override bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;}class NotchShinePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final Paint paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2.0 ..strokeCap = StrokeCap.round; // Gradient to create the "shine" effect // Fades out at the ends (top) and bottom, concentrating the shine in the middle final Shader shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: <Color>[ AppColors.accent.withValues(alpha: 0), AppColors.accent.withValues(alpha: 0.8), AppColors.accent.withValues(alpha: 0), ], stops: const <double>[0, 0.5, 1], ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); paint.shader = shader; final Path path = Path(); final double center = size.width / 2; // We draw a curve that follows the notch shape. // Starting from the top edge (y=0) and curving down. // Left side of the notch path.moveTo(center - 50, 0); path.cubicTo( center - 35, 0, // Control point 1 (flat start) center - 35, 42, // Control point 2 (curve down) center, 42 // End point (bottom center of notch) ); // Right side of the notch path.cubicTo( center + 35, 42, // Control point 1 (curve up) center + 35, 0, // Control point 2 (flat end) center + 50, 0 // End point ); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false;}class _NavBarItem extends StatelessWidget { const _NavBarItem({ required this.icon, required this.activeIcon, required this.label, required this.isSelected, required this.onTap, }); final IconData icon; final IconData activeIcon; final String label; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { final Color itemColor = isSelected ? AppColors.textPrimary : AppColors.textSubdued; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Icon( isSelected ? activeIcon : icon, color: itemColor, size: 24, ), const SizedBox(height: 4), Text( label, style: AppTypography.nav.copyWith( color: itemColor, shadows: isSelected ? const <Shadow>[ Shadow( color: Color(0xFF3A7FFF), blurRadius: 12.2, offset: Offset(0, 1), ), Shadow( color: Color(0x40FFFFFF), blurRadius: 1, offset: Offset.zero, ), ] : null, ), ), ], ), ), ); }}API reference
| React prop | Flutter parameter | Type | Default | Meaning |
|---|---|---|---|---|
| variant | the two bars of ScaffoldWithNavBar | bottom, top | bottom | The tab bar with a notch, or the header. |
| active | navigationShell.currentIndex | home, objects, forecast, settings | home | The selected tab. The web bar keeps its own state when you tap. |
| action | floatingActionButton | boolean | true | The centre action in the notch (the sky map). |
Design tokens
Every value on this component is a system token. The last column is what the app widget uses today, so the gap to close is visible.
| Role | CSS token | Dart token | App widget today |
|---|---|---|---|
| Bottom bar height | --spacing-f89 | AstrSpace.f89 | 70 + safe area |
| Notch | depth --spacing-f34, half-width --spacing-f55 | AstrSpace.f34, f55 | depth 42, half-width 50 |
| Surface | --sheen over space-grey-950 at --mag-1 | AstrColors.spaceGrey[950], Mag.m1 | AppColors.surfaceGlass #CC0C0F11 |
| Tab | an icon tile (--spacing-f34) with its label (--text-s-2) under it | AstrGlassTile(size: AstrTileSize.sm) | icon 24, label 8, no tile |
| Active tab | tile selected in deep-space-200, label lit with a deep-space-200 glow | selected: true, AstrTone.deepSpace | textPrimary, #3A7FFF shadow |
| Inactive tab | tile in space-grey-300 | tone space-grey-300 | textSubdued #7D8792 |
| Notch shine | deep-space-200, --spacing-f2 stroke | Mag.m1 | AppColors.accent, 2 |
| Centre action | a round tile, --spacing-f55 | AstrGlassTile, circular | FAB 52, circle |
| Top bar | pills --spacing-f34 tall, --radius-full | AstrSize.controlCompact | 30, radius 30 |