NakedTabs

Headless tab primitives with roving focus and typed state builders

Headless tabs component. Handles keyboard navigation (arrow keys) and selection state. Use builder pattern for custom styling.

When to use this

  • Content organization: Switch between related content sections
  • Settings panels: Group settings into categorized tabs
  • Data views: Toggle between different data representations
  • Navigation: Section navigation within a single page

Components

  • NakedTabs – owns the selected tab id and focus handling
  • NakedTab – trigger button that receives a typed state snapshot
  • NakedTabView – panel that shows/hides content based on the active tab

See the complete implementation in example/lib/api/naked_tabs.0.dart.

Basic implementation

import 'package:flutter/material.dart';
import 'package:naked_ui/naked_ui.dart';

class TabsExample extends StatefulWidget {
  const TabsExample({super.key});

  @override
  State<TabsExample> createState() => _TabsExampleState();
}

class _TabsExampleState extends State<TabsExample> {
  String _tab = 'overview';

  @override
  Widget build(BuildContext context) {
    return NakedTabs(
      selectedTabId: _tab,
      onChanged: (id) => setState(() => _tab = id),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: Colors.grey.shade100,
              borderRadius: BorderRadius.circular(12),
            ),
            child: NakedTabBar(
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: const [
                  _TabChip(tabId: 'overview', label: 'Overview'),
                  SizedBox(width: 8),
                  _TabChip(tabId: 'usage', label: 'Usage'),
                  SizedBox(width: 8),
                  _TabChip(tabId: 'api', label: 'API'),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          NakedTabView(
            tabId: 'overview',
            child: _PanelBox(
              color: Colors.blue.shade50,
              text: 'High-level description lives here.',
            ),
          ),
          NakedTabView(
            tabId: 'usage',
            child: _PanelBox(
              color: Colors.green.shade50,
              text: 'Usage guidance, best practices, etc.',
            ),
          ),
          NakedTabView(
            tabId: 'api',
            child: _PanelBox(
              color: Colors.orange.shade50,
              text: 'Detailed API summary for devs.',
            ),
          ),
        ],
      ),
    );
  }
}

class _TabChip extends StatelessWidget {
  const _TabChip({required this.tabId, required this.label});

  final String tabId;
  final String label;

  @override
  Widget build(BuildContext context) {
    return NakedTab(
      tabId: tabId,
      builder: (context, state, _) {
        final bool isFocused = state.isFocused;

        final backgroundColor = state.when(
          selected: Colors.white,
          hovered: Colors.grey.shade200,
          orElse: Colors.transparent,
        );

        return AnimatedContainer(
          duration: const Duration(milliseconds: 140),
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            color: backgroundColor,
            border: Border.all(
              color: state.when(
                selected: Colors.blue,
                focused: Colors.blue.shade300,
                orElse: Colors.grey.shade300,
              ),
              width: isFocused ? 2 : 1,
            ),
          ),
          child: const Text(
            label,
            style: TextStyle(
              fontWeight: FontWeight.w600,
              color: state.when(
                selected: Colors.blue.shade800,
                orElse: Colors.grey.shade700,
              ),
            ),
          ),
        );
      },
    );
  }
}

class _PanelBox extends StatelessWidget {
  const _PanelBox({required this.color, required this.text});

  final Color color;
  final String text;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(12),
      ),
      child: const Text(text),
    );
  }
}

Typed Tab State

NakedTabState exposes:

  • tabId → identifier for the tab
  • isSelected → convenience getter for selection state
  • widgetStates → hover/focus/pressed/disabled flags for styling

Access from Context

  • NakedTabState.of(context) / maybeOf(context) → obtain the nearest tab state
  • NakedTabState.controllerOf(context) / maybeControllerOf(context) → reach the shared WidgetStatesController

Constructors

NakedTabs

const NakedTabs({
  Key? key,
  required this.child,
  this.controller,
  this.selectedTabId,
  this.onChanged,
  this.orientation = Axis.horizontal,
  this.enabled = true,
  this.onEscapePressed,
})
  • Provide either controller or (selectedTabId with optional onChanged)
  • selectedTabId → active tab id when managing state externally
  • onChanged → handle selection changes when no controller is supplied
  • orientation → horizontal (default) or vertical arrow-key handling
  • onEscapePressed → optional callback when Escape is pressed while focused

NakedTab

const NakedTab({
  Key? key,
  this.child,
  required this.tabId,
  this.enabled = true,
  this.mouseCursor = SystemMouseCursors.click,
  this.enableFeedback = true,
  this.focusNode,
  this.autofocus = false,
  this.onFocusChange,
  this.onHoverChange,
  this.onPressChange,
  this.builder,
  this.semanticLabel,
})
  • Provide either child or builder
  • builderValueWidgetBuilder<NakedTabState>?
  • semanticLabel → override accessible name
  • interaction callbacks: onFocusChange, onHoverChange, onPressChange

NakedTabView

const NakedTabView({
  Key? key,
  required this.child,
  required this.tabId,
  this.maintainState = true,
})
  • maintainState keeps the widget subtree alive while hidden (defaults to true)

Accessibility Guidance

  • Tabs expose button semantics; Tab/Shift+Tab traverse groups
  • Arrow keys move between tabs (horizontal or vertical based on orientation)
  • Provide strong focus states via tabState.isFocused
  • Each panel should contain headings or text to clarify context for screen readers