open_ui_kit 0.8.0 copy "open_ui_kit: ^0.8.0" to clipboard
open_ui_kit: ^0.8.0 copied to clipboard

Shadcn-inspired Flutter UI kit with token-driven, composable, and responsive components.

example/lib/main.dart

import 'package:flutter/widgets.dart';
import 'package:lucide_flutter/lucide_flutter.dart';
import 'package:open_ui_kit/open_ui_kit.dart';

void main() {
  runApp(const _ContourDemoApp());
}

class _ContourDemoApp extends StatelessWidget {
  const _ContourDemoApp();

  @override
  Widget build(BuildContext context) {
    return UiApp(
      title: 'Contour demo',
      localizationsDelegates: const [DefaultWidgetsLocalizations.delegate],
      home: const _ContourDemoPage(),
    );
  }
}

class _ContourDemoPage extends StatefulWidget {
  const _ContourDemoPage();

  @override
  State<_ContourDemoPage> createState() => _ContourDemoPageState();
}

class _ContourDemoPageState extends State<_ContourDemoPage> {
  String _status = 'No action taken yet.';

  void _handle(String action) {
    setState(
      () => _status =
          '$action at ${DateTime.now().toIso8601String().substring(11, 19)}',
    );
  }

  List<UiContourReleaseAction> _actions({bool archiveDisabled = false}) {
    return [
      UiContourReleaseAction(
        icon: const Icon(LucideIcons.reply),
        semanticsLabel: 'Reply',
        onPressed: () => _handle('Replied'),
      ),
      UiContourReleaseAction(
        icon: const Icon(LucideIcons.archive),
        semanticsLabel: 'Archive',
        onPressed: archiveDisabled ? null : () => _handle('Archived'),
      ),
      UiContourReleaseAction(
        icon: const Icon(LucideIcons.star),
        semanticsLabel: 'Favorite',
        onPressed: () => _handle('Favorited'),
      ),
    ];
  }

  @override
  Widget build(BuildContext context) {
    final tokens = UiThemeTokens.of(context);
    return DecoratedBox(
      decoration: BoxDecoration(color: tokens.colors.background),
      child: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(24),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const UiText('Message toolbar', variant: UiTextVariant.heading),
              const SizedBox(height: 8),
              UiText(_status, variant: UiTextVariant.caption),
              const SizedBox(height: 16),
              _ToolCard(
                child: UiContourRelease(
                  label: 'More',
                  intent: UiIntent.neutral,
                  actions: _actions(),
                ),
              ),
              const SizedBox(height: 32),
              const UiText(
                'One action disabled',
                variant: UiTextVariant.subheading,
              ),
              const SizedBox(height: 12),
              _ToolCard(
                child: UiContourRelease(
                  label: 'More',
                  intent: UiIntent.neutral,
                  actions: _actions(archiveDisabled: true),
                ),
              ),
              const SizedBox(height: 32),
              const UiText('RTL', variant: UiTextVariant.subheading),
              const SizedBox(height: 12),
              _ToolCard(
                child: Directionality(
                  textDirection: TextDirection.rtl,
                  child: UiContourRelease(
                    label: 'المزيد',
                    intent: UiIntent.neutral,
                    actions: _actions(),
                  ),
                ),
              ),
              const SizedBox(height: 32),
              const UiText('Reduced motion', variant: UiTextVariant.subheading),
              const SizedBox(height: 12),
              _ToolCard(
                child: MediaQuery(
                  data: MediaQuery.of(context)
                      .copyWith(disableAnimations: true),
                  child: UiContourRelease(
                    label: 'More',
                    intent: UiIntent.neutral,
                    actions: _actions(),
                  ),
                ),
              ),
              const SizedBox(height: 40),
              const UiText(
                'Bottom nav → search accessory',
                variant: UiTextVariant.heading,
              ),
              const SizedBox(height: 8),
              const UiText(
                'Two independent surfaces sharing one progress timeline: the '
                'bar recedes by exactly the width the search accessory '
                'claims, which grows from the search icon\'s position with a '
                'bounded backdrop blur — not a shared capsule.',
                variant: UiTextVariant.body,
              ),
              const SizedBox(height: 16),
              SizedBox(
                width: 360,
                child: UiContourAccessoryRelease(
                  intent: UiIntent.neutral,
                  items: [
                    UiContourBarItem(
                      icon: const Icon(LucideIcons.house),
                      semanticsLabel: 'Home',
                      onPressed: () {},
                    ),
                    UiContourBarItem(
                      icon: const Icon(LucideIcons.messageSquare),
                      semanticsLabel: 'Messages',
                      onPressed: () {},
                    ),
                    UiContourBarItem(
                      icon: const Icon(LucideIcons.user),
                      semanticsLabel: 'Profile',
                      onPressed: () {},
                    ),
                  ],
                  accessoryChild: const UiText(
                    'Search messages…',
                    variant: UiTextVariant.body,
                  ),
                ),
              ),
              const SizedBox(height: 40),
              const UiText(
                'The real UiBottomTabBar already has this',
                variant: UiTextVariant.heading,
              ),
              const SizedBox(height: 8),
              const UiText(
                'UiBottomTabBar ships its own UiBottomTabAccessory: tapping '
                'search shrinks the selected tab into its own island and the '
                'accessory takes the freed width — the production dock, '
                'blur, and shadow, unmodified. Home has no accessory at all, '
                'so switching to/from it exercises presence (the accessory '
                'shell itself growing/shrinking away). Switching directly '
                'between Messages and Profile instead exercises the '
                'crossfade layer: the accessory shell stays put and only its '
                'icon + placeholder cross-dissolve.',
                variant: UiTextVariant.body,
              ),
              const SizedBox(height: 16),
              const RepaintBoundary(
                key: ValueKey('real-bottom-tab-accessory-demo'),
                child: _RealBottomTabAccessoryDemo(),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// A bounded "device frame" hosting the kit's real, unmodified
/// [UiBottomTabScaffold] + [UiBottomTabAccessory] so the existing production
/// mechanism can be previewed the same way the Contour prototypes above
/// are — nothing here is a copy or a reimplementation.
///
/// This deliberately goes through [UiBottomTabScaffold], not raw
/// [UiBottomTabBar] directly — [UiBottomTabBar] is documented as a
/// low-level, caller-driven primitive whose `accessoryPresence` the caller
/// must animate itself (analogous to `UiContourController` needing a
/// caller to drive it). `UiBottomTabScaffold` is the integration point that
/// actually owns that animation (via `UiContourPresenceController`); using
/// the bar directly, as an earlier revision of this demo did, bypasses it
/// entirely and made the accessory appear to snap in with no transition.
class _RealBottomTabAccessoryDemo extends StatefulWidget {
  const _RealBottomTabAccessoryDemo();

  @override
  State<_RealBottomTabAccessoryDemo> createState() =>
      _RealBottomTabAccessoryDemoState();
}

class _RealBottomTabAccessoryDemoState
    extends State<_RealBottomTabAccessoryDemo> {
  int _currentIndex = 0;
  bool _searchExpanded = false;

  static const _items = [
    UiBottomTabItem(label: 'Home', icon: Icon(LucideIcons.house)),
    UiBottomTabItem(label: 'Messages', icon: Icon(LucideIcons.messageSquare)),
    UiBottomTabItem(label: 'Profile', icon: Icon(LucideIcons.user)),
  ];

  IconData _searchIconFor(int index) => switch (index) {
    1 => LucideIcons.mail,
    2 => LucideIcons.userSearch,
    _ => LucideIcons.search,
  };

  String _searchPlaceholderFor(int index) => switch (index) {
    1 => 'Search messages…',
    2 => 'Search people…',
    _ => 'Search…',
  };

  @override
  Widget build(BuildContext context) {
    final tokens = UiThemeTokens.of(context);
    // Captured here, once per real build of *this* state — not inside any
    // nested builder closure. A closure that instead read `_currentIndex`
    // directly would only evaluate when its own Element later rebuilds,
    // which happens after `_currentIndex` has already moved on — so the
    // *retained* (fading-out) accessory instance would end up rendering the
    // *new* tab's icon too, defeating the cross-dissolve. Plain locals
    // closed over here are fixed the moment this build runs.
    final searchIcon = _searchIconFor(_currentIndex);
    final searchPlaceholder = _searchPlaceholderFor(_currentIndex);
    return DecoratedBox(
      decoration: BoxDecoration(
        color: tokens.colors.surfaceMuted,
        border: Border.all(color: tokens.colors.border),
        borderRadius: tokens.radius.mdAll,
      ),
      child: ClipRRect(
        borderRadius: tokens.radius.mdAll,
        child: SizedBox(
          width: 360,
          height: 280,
          child: UiBottomTabScaffold(
            items: _items,
            currentIndex: _currentIndex,
            onChanged: (i) => setState(() {
              _currentIndex = i;
              // Home has no search accessory; switching to it must not
              // leave a stale expanded search behind.
              if (i == 0) _searchExpanded = false;
            }),
            // One page per tab — the scaffold owns page preservation and
            // the floating-dock layout around whichever page is showing.
            pages: [
              for (final item in _items)
                Center(
                  child: UiText(
                    '${item.label} page content behind the dock',
                    variant: UiTextVariant.caption,
                    tone: UiTextTone.muted,
                  ),
                ),
            ],
            // Only Messages/Profile offer search — Home does not. Home
            // tests presence (existence appearing/disappearing). Messages
            // <-> Profile tests the *other* case: the accessory shell stays
            // in place the whole time and only its content — a different
            // icon and placeholder per tab — cross-dissolves. Using the
            // exact same content on both tabs would make that transition
            // invisible even if it were firing correctly.
            bottomAccessory: _currentIndex == 0
                ? null
                : UiBottomTabAccessory(
                    expanded: _searchExpanded,
                    leadingItem: _items[_currentIndex],
                    onLeadingPressed: () =>
                        setState(() => _searchExpanded = false),
                    child: _searchExpanded
                        ? Padding(
                            padding: const EdgeInsets.symmetric(horizontal: 12),
                            child: Row(
                              children: [
                                Icon(searchIcon, size: 16),
                                const SizedBox(width: 8),
                                Expanded(
                                  child: UiText(
                                    searchPlaceholder,
                                    variant: UiTextVariant.body,
                                  ),
                                ),
                              ],
                            ),
                          )
                        : UiPressable(
                            onPressed: () =>
                                setState(() => _searchExpanded = true),
                            semanticsLabel: 'Search',
                            builder: (context, state, _) =>
                                Center(child: Icon(searchIcon, size: 18)),
                          ),
                  ),
          ),
        ),
      ),
    );
  }
}

/// A stable-width card: it does not shrink-wrap [child], so the card itself
/// never grows or shrinks as the Contour control inside it expands and
/// collapses — only the control's own hairline-bordered surface should
/// visibly change shape. Trailing placeholder content makes the available
/// space, and the control's origin/destination within it, legible.
class _ToolCard extends StatelessWidget {
  const _ToolCard({required this.child});

  final Widget child;

  static const _width = 360.0;

  @override
  Widget build(BuildContext context) {
    final tokens = UiThemeTokens.of(context);
    return DecoratedBox(
      decoration: BoxDecoration(
        color: tokens.colors.surface,
        border: Border.all(color: tokens.colors.border),
        borderRadius: tokens.radius.mdAll,
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: SizedBox(
          width: _width,
          child: Row(
            children: [
              child,
              const Spacer(),
              Container(
                width: 96,
                height: 12,
                decoration: BoxDecoration(
                  color: tokens.colors.surfaceMuted,
                  borderRadius: tokens.radius.smAll,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
0
likes
160
points
485
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Shadcn-inspired Flutter UI kit with token-driven, composable, and responsive components.

Repository (GitHub)
View/report issues

Topics

#design-system #flutter #shadcn-ui #ui #widget

License

MIT (license)

Dependencies

flutter, lucide_flutter

More

Packages that depend on open_ui_kit