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 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 tabisSelected
→ convenience getter for selection statewidgetStates
→ 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
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 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({
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
orbuilder
builder
→ValueWidgetBuilder<NakedTabState>?
semanticLabel
→ override accessible name- interaction callbacks:
onFocusChange
,onHoverChange
,onPressChange