liquid_glass_hig 0.1.0-beta.3
liquid_glass_hig: ^0.1.0-beta.3 copied to clipboard
iOS 26 Liquid Glass UI primitives for Flutter — containers, sheets, nav bars, controls. Pixel-matched to Apple's HIG. Material 3 fallback on Android.
import 'package:flutter/material.dart';
import 'package:liquid_glass_hig/liquid_glass.dart';
void main() {
runApp(const LiquidGlassDemoApp());
}
class LiquidGlassDemoApp extends StatefulWidget {
const LiquidGlassDemoApp({super.key});
@override
State<LiquidGlassDemoApp> createState() => _LiquidGlassDemoAppState();
}
class _LiquidGlassDemoAppState extends State<LiquidGlassDemoApp> {
LiquidGlassPreset _preset = LiquidGlassPreset.frosted;
int _tab = 2; // land on Components page
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'liquid_glass demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
),
builder: (context, child) {
final theme = LiquidGlassTheme.fromColorScheme(
Theme.of(context).colorScheme,
preset: _preset,
);
return LiquidGlassThemeScope(theme: theme, child: child!);
},
home: Stack(
children: [
const _Wallpaper(),
DemoShell(
preset: _preset,
onPresetChanged: (p) => setState(() => _preset = p),
tabIndex: _tab,
onTabChanged: (i) => setState(() => _tab = i),
),
],
),
);
}
}
class _Wallpaper extends StatelessWidget {
const _Wallpaper();
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _WallpaperPainter(),
size: Size.infinite,
);
}
}
class _WallpaperPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Base gradient (the wash).
final base = Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFF6A88),
Color(0xFFFF99AC),
Color(0xFFFFCB6B),
],
).createShader(Offset.zero & size);
canvas.drawRect(Offset.zero & size, base);
// Overlapping coloured blobs — these are what the glass will refract.
final blobs = <(Offset, double, Color)>[
(Offset(size.width * 0.20, size.height * 0.18), size.width * 0.55, const Color(0xFF6B5BFF)),
(Offset(size.width * 0.85, size.height * 0.30), size.width * 0.45, const Color(0xFFFFB400)),
(Offset(size.width * 0.10, size.height * 0.62), size.width * 0.50, const Color(0xFF00C2FF)),
(Offset(size.width * 0.80, size.height * 0.78), size.width * 0.55, const Color(0xFFFF3FA5)),
(Offset(size.width * 0.55, size.height * 0.50), size.width * 0.40, const Color(0xFF8AFF6B)),
];
for (final (center, radius, color) in blobs) {
final p = Paint()
..shader = RadialGradient(
colors: [color.withValues(alpha: 0.85), color.withValues(alpha: 0.0)],
).createShader(Rect.fromCircle(center: center, radius: radius));
canvas.drawCircle(center, radius, p);
}
// Diagonal highlight strip to break up the field further.
final highlight = Paint()
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.18),
Colors.white.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.12),
],
stops: const [0, 0.55, 1],
).createShader(Offset.zero & size);
canvas.drawRect(Offset.zero & size, highlight);
}
@override
bool shouldRepaint(covariant _WallpaperPainter oldDelegate) => false;
}
class DemoShell extends StatefulWidget {
const DemoShell({
super.key,
required this.preset,
required this.onPresetChanged,
required this.tabIndex,
required this.onTabChanged,
});
final LiquidGlassPreset preset;
final ValueChanged<LiquidGlassPreset> onPresetChanged;
final int tabIndex;
final ValueChanged<int> onTabChanged;
@override
State<DemoShell> createState() => _DemoShellState();
}
class _DemoShellState extends State<DemoShell> {
final ScrollController _scroll = ScrollController();
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget page;
String title;
switch (widget.tabIndex) {
case 0:
title = 'Settings';
page = _SettingsPage(scroll: _scroll);
break;
case 1:
title = 'Now Playing';
page = _MusicPage(scroll: _scroll);
break;
case 2:
title = 'Components';
page = _ComponentsPage(
scroll: _scroll,
preset: widget.preset,
onPresetChanged: widget.onPresetChanged,
);
break;
default:
title = 'Settings';
page = _SettingsPage(scroll: _scroll);
}
return Scaffold(
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
extendBody: true,
appBar: GlassScrollAwareNavigationBar(
scrollController: _scroll,
title: title,
),
body: page,
bottomNavigationBar: GlassTabBar(
items: const [
GlassTabItem(
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
label: 'Settings',
),
GlassTabItem(
icon: Icons.play_circle_outline,
selectedIcon: Icons.play_circle,
label: 'Music',
),
GlassTabItem(
icon: Icons.widgets_outlined,
selectedIcon: Icons.widgets,
label: 'Components',
),
],
currentIndex: widget.tabIndex,
onTap: widget.onTabChanged,
),
);
}
}
class _SettingsPage extends StatefulWidget {
const _SettingsPage({required this.scroll});
final ScrollController scroll;
@override
State<_SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<_SettingsPage> {
bool _airplane = false;
bool _wifi = true;
bool _bluetooth = true;
@override
Widget build(BuildContext context) {
return ListView(
controller: widget.scroll,
padding: EdgeInsets.only(
top: MediaQuery.paddingOf(context).top + kToolbarHeight + 8,
bottom: 100,
),
children: [
GlassSection(
rows: [
GlassSectionRow(
title: 'Airplane Mode',
leading: const Icon(Icons.airplanemode_active, color: Colors.orange),
trailing: GlassToggle(
value: _airplane,
onChanged: (v) => setState(() => _airplane = v),
),
),
GlassSectionRow(
title: 'Wi-Fi',
leading: const Icon(Icons.wifi, color: Colors.blue),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('HomeNet'),
const SizedBox(width: 6),
GlassToggle(
value: _wifi,
onChanged: (v) => setState(() => _wifi = v),
),
],
),
onTap: () {},
),
GlassSectionRow(
title: 'Bluetooth',
leading: const Icon(Icons.bluetooth, color: Colors.blue),
trailing: GlassToggle(
value: _bluetooth,
onChanged: (v) => setState(() => _bluetooth = v),
),
),
GlassSectionRow(
title: 'Cellular',
leading: const Icon(Icons.network_cell, color: Colors.green),
trailing: const Text('Off'),
onTap: () {},
),
],
),
GlassSection(
title: 'general',
rows: [
GlassSectionRow(
title: 'About',
leading: const Icon(Icons.info_outline, color: Colors.grey),
onTap: () {},
),
GlassSectionRow(
title: 'Software Update',
leading: const Icon(Icons.system_update, color: Colors.indigo),
trailing: const Text('iOS 26.1'),
onTap: () {},
),
GlassSectionRow(
title: 'AirDrop',
leading: const Icon(Icons.share, color: Colors.blue),
onTap: () => showGlassActionSheet<void>(
context: context,
title: 'AirDrop',
message: 'Choose who can send you content.',
actions: [
GlassActionSheetAction(
label: 'Receiving Off',
onPressed: () => Navigator.pop(context),
),
GlassActionSheetAction(
label: 'Contacts Only',
onPressed: () => Navigator.pop(context),
),
GlassActionSheetAction(
label: 'Everyone for 10 Minutes',
onPressed: () => Navigator.pop(context),
),
],
cancel: GlassActionSheetAction(
label: 'Cancel',
onPressed: () => Navigator.pop(context),
),
),
),
],
footer: 'Updates download in the background.',
),
GlassSection(
title: 'danger zone',
rows: [
GlassSectionRow(
title: 'Reset',
leading: const Icon(Icons.refresh, color: Colors.red),
onTap: () => showGlassDialog<void>(
context: context,
title: 'Reset all settings?',
message:
'This will return every setting on this device to its default. Your data will not be touched.',
actions: [
GlassDialogAction(
label: 'Cancel',
onPressed: () => Navigator.pop(context),
),
GlassDialogAction(
label: 'Reset',
variant: GlassButtonVariant.primary,
onPressed: () => Navigator.pop(context),
),
],
),
),
],
),
],
);
}
}
class _MusicPage extends StatefulWidget {
const _MusicPage({required this.scroll});
final ScrollController scroll;
@override
State<_MusicPage> createState() => _MusicPageState();
}
class _MusicPageState extends State<_MusicPage> {
String _filter = 'all';
@override
Widget build(BuildContext context) {
final theme = LiquidGlassThemeScope.of(context);
return ListView(
controller: widget.scroll,
padding: EdgeInsets.only(
top: MediaQuery.paddingOf(context).top + kToolbarHeight + 8,
bottom: 100,
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: GlassSearchBar(
placeholder: 'Find a song',
onChanged: (_) {},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: GlassSegmentedControl<String>(
segments: const {
'all': Text('All'),
'downloaded': Text('Downloaded'),
'recent': Text('Recent'),
},
groupValue: _filter,
onValueChanged: (v) => setState(() => _filter = v),
),
),
GlassPanel(
title: 'now playing',
child: Row(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: const LinearGradient(
colors: [Color(0xFFFFD86A), Color(0xFFFF6A95)],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Glasshouse',
style: TextStyle(
color: theme.foreground,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
Text(
'The Refractions',
style: TextStyle(
color: theme.mutedForeground,
fontSize: 13,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.pause_circle_filled),
color: theme.accent,
iconSize: 32,
onPressed: () {},
),
],
),
),
const _FavoritesChips(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: GlassButton(
label: 'Add to library',
icon: Icons.add,
variant: GlassButtonVariant.primary,
expand: true,
onPressed: () {},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: GlassButton(
label: 'Share',
icon: Icons.ios_share,
variant: GlassButtonVariant.secondary,
expand: true,
onPressed: () => showGlassBottomSheet<void>(
context: context,
builder: (sheetContext) => const _ShareSheet(),
),
),
),
],
);
}
}
class _FavoritesChips extends StatelessWidget {
const _FavoritesChips();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: const [
GlassChip(label: 'Pop', icon: Icons.tag, selected: true),
GlassChip(label: 'Jazz', icon: Icons.tag),
GlassChip(label: 'Lo-Fi', icon: Icons.tag),
GlassChip(label: 'Electronic', icon: Icons.tag),
],
),
);
}
}
class _ShareSheet extends StatelessWidget {
const _ShareSheet();
@override
Widget build(BuildContext context) {
final theme = LiquidGlassThemeScope.of(context);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Share Glasshouse',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: theme.foreground,
),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.spaceEvenly,
spacing: 12,
children: const [
_ShareIcon(icon: Icons.messenger_outline, label: 'Messages'),
_ShareIcon(icon: Icons.mail_outline, label: 'Mail'),
_ShareIcon(icon: Icons.copy, label: 'Copy'),
_ShareIcon(icon: Icons.bookmark_outline, label: 'Save'),
],
),
const SizedBox(height: 16),
GlassButton(
label: 'Close',
expand: true,
onPressed: () => Navigator.pop(context),
),
],
),
);
}
}
class _ShareIcon extends StatelessWidget {
const _ShareIcon({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final theme = LiquidGlassThemeScope.of(context);
return Column(
children: [
GlassCard(
padding: const EdgeInsets.all(12),
onTap: () {},
child: Icon(icon, color: theme.accent),
),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 11, color: theme.foreground)),
],
);
}
}
class _ComponentsPage extends StatefulWidget {
const _ComponentsPage({
required this.scroll,
required this.preset,
required this.onPresetChanged,
});
final ScrollController scroll;
final LiquidGlassPreset preset;
final ValueChanged<LiquidGlassPreset> onPresetChanged;
@override
State<_ComponentsPage> createState() => _ComponentsPageState();
}
class _ComponentsPageState extends State<_ComponentsPage> {
bool _toggleA = true;
bool _toggleB = false;
String _filter = 'all';
String _chip = 'pop';
@override
Widget build(BuildContext context) {
final theme = LiquidGlassThemeScope.of(context);
return ListView(
controller: widget.scroll,
padding: EdgeInsets.only(
top: MediaQuery.paddingOf(context).top + kToolbarHeight + 8,
bottom: 100,
),
children: [
// Hero preset switcher — biggest CTA on the page.
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: GlassPanel(
title: 'preset',
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Switch the global theme to see thickness, refraction, and rim light react live.',
style: TextStyle(
fontSize: 12,
color: theme.mutedForeground,
height: 1.3,
),
),
const SizedBox(height: 12),
GlassSegmentedControl<LiquidGlassPreset>(
segments: const {
LiquidGlassPreset.clear: Text('Clear'),
LiquidGlassPreset.frosted: Text('Frosted'),
LiquidGlassPreset.vibrant: Text('Vibrant'),
},
groupValue: widget.preset,
onValueChanged: widget.onPresetChanged,
),
],
),
),
),
// Hero droplet gallery — the money shot.
const _DropletGallery(),
// Cards & containers.
GlassPanel(
title: 'cards & containers',
child: Column(
children: [
Row(
children: [
Expanded(
child: GlassCard(
padding: const EdgeInsets.symmetric(vertical: 24),
onTap: () {},
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.touch_app, color: theme.accent, size: 22),
const SizedBox(height: 4),
Text(
'GlassCard',
style: TextStyle(
color: theme.foreground,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: GlassContainer(
height: 84,
alignment: Alignment.center,
child: Text(
'GlassContainer',
style: TextStyle(
color: theme.foreground,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
),
],
),
],
),
),
// Buttons.
GlassPanel(
title: 'buttons',
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
GlassButton(
label: 'Primary',
icon: Icons.check,
variant: GlassButtonVariant.primary,
onPressed: () {},
),
GlassButton(
label: 'Secondary',
variant: GlassButtonVariant.secondary,
onPressed: () {},
),
GlassButton(
label: 'Tertiary',
variant: GlassButtonVariant.tertiary,
onPressed: () {},
),
const GlassButton(label: 'Disabled'),
],
),
),
// Toggles + chips.
GlassPanel(
title: 'toggles & chips',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Notifications',
style: TextStyle(color: theme.foreground, fontSize: 14),
),
GlassToggle(
value: _toggleA,
onChanged: (v) => setState(() => _toggleA = v),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Reduce motion',
style: TextStyle(color: theme.foreground, fontSize: 14),
),
GlassToggle(
value: _toggleB,
onChanged: (v) => setState(() => _toggleB = v),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in ['pop', 'jazz', 'lo-fi', 'electronic'])
GlassChip(
label: tag,
icon: Icons.tag,
selected: _chip == tag,
onTap: () => setState(() => _chip = tag),
),
],
),
],
),
),
// Search bar + segmented control inline.
GlassPanel(
title: 'inputs',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
GlassSearchBar(
placeholder: 'Search components',
onChanged: (_) {},
),
const SizedBox(height: 10),
GlassSegmentedControl<String>(
segments: const {
'all': Text('All'),
'inputs': Text('Inputs'),
'modals': Text('Modals'),
},
groupValue: _filter,
onValueChanged: (v) => setState(() => _filter = v),
),
],
),
),
// Modal triggers — launches each modal type.
GlassPanel(
title: 'modals',
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
GlassButton(
label: 'Bottom sheet',
icon: Icons.swipe_up,
onPressed: () => showGlassBottomSheet<void>(
context: context,
builder: (sheetCtx) => Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Glass bottom sheet',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: theme.foreground,
),
),
const SizedBox(height: 8),
Text(
'Drag down or tap outside to dismiss.',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.mutedForeground,
fontSize: 13,
),
),
const SizedBox(height: 16),
GlassButton(
label: 'Close',
expand: true,
onPressed: () => Navigator.pop(sheetCtx),
),
],
),
),
),
),
GlassButton(
label: 'Dialog',
icon: Icons.chat_bubble_outline,
onPressed: () => showGlassDialog<void>(
context: context,
title: 'Delete this item?',
message:
'This will permanently remove it from your library.',
actions: [
GlassDialogAction(
label: 'Cancel',
onPressed: () => Navigator.pop(context),
),
GlassDialogAction(
label: 'Delete',
variant: GlassButtonVariant.primary,
onPressed: () => Navigator.pop(context),
),
],
),
),
GlassButton(
label: 'Action sheet',
icon: Icons.list_alt,
onPressed: () => showGlassActionSheet<void>(
context: context,
title: 'Share via',
actions: [
GlassActionSheetAction(
label: 'Messages',
icon: Icons.chat_bubble_outline,
onPressed: () => Navigator.pop(context),
),
GlassActionSheetAction(
label: 'Mail',
icon: Icons.mail_outline,
onPressed: () => Navigator.pop(context),
),
GlassActionSheetAction(
label: 'Remove',
icon: Icons.delete_outline,
isDestructive: true,
onPressed: () => Navigator.pop(context),
),
],
cancel: GlassActionSheetAction(
label: 'Cancel',
onPressed: () => Navigator.pop(context),
),
),
),
],
),
),
// Floating toolbar.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'FLOATING TOOLBAR',
style: TextStyle(
fontSize: 12,
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: theme.mutedForeground,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: GlassToolbar(
children: [
IconButton(
onPressed: () {},
icon: Icon(Icons.format_bold, color: theme.foreground),
),
IconButton(
onPressed: () {},
icon: Icon(Icons.format_italic, color: theme.foreground),
),
IconButton(
onPressed: () {},
icon: Icon(Icons.format_underline, color: theme.foreground),
),
IconButton(
onPressed: () {},
icon: Icon(Icons.link, color: theme.foreground),
),
],
),
),
],
);
}
}
/// Three free-floating glass beads of different sizes/shapes — the visual
/// money shot of the Components page. Sits over the textured wallpaper so
/// the refraction reads at a glance.
class _DropletGallery extends StatelessWidget {
const _DropletGallery();
@override
Widget build(BuildContext context) {
final theme = LiquidGlassThemeScope.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 8),
child: Text(
'DROPLETS',
style: TextStyle(
fontSize: 12,
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: theme.mutedForeground,
),
),
),
SizedBox(
height: 140,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Small pill
LiquidGlass(
borderRadius: BorderRadius.circular(60),
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 14,
),
child: Icon(
Icons.water_drop,
color: theme.foreground,
size: 22,
),
),
// Medium square-ish
LiquidGlass(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bolt,
color: theme.accent,
size: 28,
),
const SizedBox(height: 4),
Text(
'Glass',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.foreground,
),
),
],
),
),
// Big round
LiquidGlass(
borderRadius: BorderRadius.circular(60),
padding: const EdgeInsets.all(24),
child: Icon(
Icons.brightness_2,
color: theme.foreground,
size: 36,
),
),
],
),
),
],
),
);
}
}