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 handlingNakedTab– trigger button that receives a typed state snapshotNakedTabView– panel that shows/hides content based on the active tab
See the complete implementation in apps/naked_ui_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: 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: Text(text),
);
}
}Typed Tab State
NakedTabState exposes:
tabId→ identifier for the tabisSelected→ convenience getter for selection statestates→ hover/focus/pressed/disabled flags for styling
Access from Context
NakedTabState.of(context)/maybeOf(context)→ obtain the nearest tab stateNakedTabState.controllerOf(context)/maybeControllerOf(context)→ reach the sharedWidgetStatesController
Constructors
NakedTabs
const NakedTabs({
super.key,
required this.child,
this.controller,
this.selectedTabId,
this.onChanged,
this.orientation = Axis.horizontal,
this.activationMode = NakedTabActivationMode.automatic,
this.enabled = true,
this.onEscapePressed,
})- Provide either
controlleror (selectedTabIdwith optionalonChanged) selectedTabId→ active tab id when managing state externallyonChanged→ handle selection changes when no controller is suppliedorientation→ horizontal (default) or vertical arrow-key handlingonEscapePressed→ optional callback when Escape is pressed while focused
NakedTab
const NakedTab({
super.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,
this.excludeSemantics = false,
})- Provide either
childorbuilder builder→ValueWidgetBuilder<NakedTabState>?semanticLabel→ override accessible nameexcludeSemantics→ hide the tab and its descendants from the semantic tree- interaction callbacks:
onFocusChange,onHoverChange,onPressChange
NakedTabView
const NakedTabView({
super.key,
required this.child,
required this.tabId,
this.maintainState = true,
})maintainStatekeeps 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
NakedTabBar groups the triggers. activationMode chooses automatic activation on focus or manual activation. Tabs expose the tab role as well as button semantics.