Flip Calendar
A customizable Flutter month calendar widget with realistic page-turn animations and swipe gesture navigation, powered by page_turn_animation.
![]() |
![]() |
![]() |
![]() |
| Top | Bottom | Left | Right |
Features
- Realistic 3D page curl effect when navigating between months
- Swipe gesture navigation with flick detection
- Fully customizable day cell rendering via builder
- Programmatic navigation with
CalendarController - Date constraints (min/max) with support for fixed, relative, and dynamic dates
- Configurable bound edge (top, bottom, left, right)
- Sequential or direct-jump multi-month animation modes
- Built-in light and dark theme presets
- Haptic feedback hooks for navigation events
- Works with any first day of week (Monday, Sunday, etc.)
Installation
Add the package to your pubspec.yaml:
dependencies:
flip_calendar: ^0.1.0
page_turn_animation: ^0.1.4
Then run:
flutter pub get
Quick Start
import 'package:flip_calendar/flip_calendar.dart';
import 'package:page_turn_animation/page_turn_animation.dart';
class MyCalendar extends StatefulWidget {
@override
State<MyCalendar> createState() => _MyCalendarState();
}
class _MyCalendarState extends State<MyCalendar> {
final _controller = CalendarController(initialMonth: DateTime.now());
DateTime? _selectedDate;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FlipCalendar(
controller: _controller,
selectedDate: _selectedDate,
onDayTap: (date) => setState(() => _selectedDate = date),
dayBuilder: (context, data) {
return Center(
child: Text(
data.date.day.toString(),
style: TextStyle(
color: data.isCurrentMonth ? Colors.black : Colors.grey[400],
fontWeight: data.isToday ? FontWeight.bold : FontWeight.normal,
),
),
);
},
);
}
}
Usage Guide
CalendarController
The CalendarController manages the displayed month and exposes animation state. Create it once and pass it to FlipCalendar:
final controller = CalendarController(initialMonth: DateTime(2025, 6, 1));
// Listen for changes
controller.addListener(() {
print('Month: ${controller.currentMonth}');
print('Animating: ${controller.isAnimating}');
});
// Programmatic navigation
controller.nextMonth();
controller.previousMonth();
controller.goToMonth(DateTime(2025, 12, 1));
controller.goToToday();
controller.goToYearMonth(2026, 3);
The isAnimating property is useful for disabling external UI (such as header navigation buttons) while a page-turn animation is in progress.
Day Builder
The dayBuilder callback receives a CalendarDayData object with everything you need to render each cell:
FlipCalendar(
controller: controller,
dayBuilder: (context, data) {
// data.date — the date this cell represents
// data.isCurrentMonth — false for overflow days from adjacent months
// data.isToday — whether this date is today
// data.isSelected — whether this date matches selectedDate
// data.isFutureDate — whether this date is after today
// data.isEnabled — whether this cell is within date bounds
// data.row / data.column — grid position (0-based)
return Center(
child: Text(
data.date.day.toString(),
style: TextStyle(
color: data.isEnabled ? Colors.black : Colors.grey,
),
),
);
},
)
Date Constraints
Control the navigable and selectable date range using DateConstraint:
// Fixed date
FlipCalendar(
controller: controller,
minDate: DateConstraint.fixed(DateTime(2020, 1, 1)),
maxDate: DateConstraint.fixed(DateTime(2030, 12, 31)),
// ...
)
// Dynamic: today recomputes each build
FlipCalendar(
controller: controller,
maxDate: DateConstraint.today(),
// ...
)
// Relative: 2 years back, 1 year ahead
FlipCalendar(
controller: controller,
minDate: DateConstraint.relative(years: -2),
maxDate: DateConstraint.relative(years: 1),
// ...
)
When a user swipes toward a restricted month, the gesture is clamped to a small bounce and the onHapticFeedback callback fires with CalendarHapticType.navigationRestricted.
Choosing the Bound Edge
The boundEdge parameter controls which edge the page curls over, and determines whether swipe gestures are vertical or horizontal:
// Top-bound (default): swipe up/down to navigate
FlipCalendar(
controller: controller,
boundEdge: PageTurnEdge.top,
// ...
)
// Right-bound: swipe left/right like a book
FlipCalendar(
controller: controller,
boundEdge: PageTurnEdge.right,
// ...
)
| Edge | Gesture | Use Case |
|---|---|---|
top |
Vertical (swipe up/down) | Top-bound notepad (default) |
bottom |
Vertical (swipe up/down) | Wall calendar, bottom-bound pad |
left |
Horizontal (swipe left/right) | Right-to-left book, manga |
right |
Horizontal (swipe left/right) | Left-to-right book, standard Western reading |
Multi-Month Animation Modes
When navigating multiple months at once (e.g., jumping from January to June programmatically), you can choose how the animation behaves:
// Sequential: flips through each intermediate month (default)
FlipCalendar(
controller: controller,
multiMonthAnimationMode: MultiMonthAnimationMode.sequential,
maxAnimatedMonthJump: 6, // Skip animation beyond 6 months
// ...
)
// Direct jump: single flip from start to end month
FlipCalendar(
controller: controller,
multiMonthAnimationMode: MultiMonthAnimationMode.directJump,
// ...
)
| Mode | Behavior |
|---|---|
sequential |
Flips through each month in sequence. Skips animation if jump exceeds maxAnimatedMonthJump. |
directJump |
Single page flip directly from origin to destination, regardless of distance. |
First Day of Week
// Start weeks on Monday
FlipCalendar(
controller: controller,
firstDayOfWeek: DateTime.monday,
// ...
)
// Start weeks on Sunday (default)
FlipCalendar(
controller: controller,
firstDayOfWeek: DateTime.sunday,
// ...
)
The weekday header row automatically rotates to match.
Haptic Feedback
The calendar exposes haptic events via a callback — you implement the actual feedback:
import 'package:flutter/services.dart';
FlipCalendar(
controller: controller,
onHapticFeedback: (type) {
switch (type) {
case CalendarHapticType.navigationRestricted:
HapticFeedback.heavyImpact();
}
},
// ...
)
Disabling Animation and Gestures
// Static calendar with no animations or gestures
FlipCalendar(
controller: controller,
animationsEnabled: false,
gesturesEnabled: false,
// ...
)
// Animated programmatic navigation, but no swipe gestures
FlipCalendar(
controller: controller,
animationsEnabled: true,
gesturesEnabled: false,
// ...
)
Customization
CalendarStyle
Customize the visual appearance with CalendarStyle:
FlipCalendar(
controller: controller,
style: CalendarStyle(
// Background
calendarBackground: Colors.white,
padding: EdgeInsets.all(8),
borderRadius: BorderRadius.circular(12),
// Grid
gridLineColor: Colors.grey[300]!,
gridLineWidth: 1.0,
// Weekday header
weekdayHeaderBackground: Colors.grey[100]!,
weekdayHeaderTextColor: Colors.black87,
weekdayHeaderHeight: 40.0,
weekdayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
// Today indicator
todayBorderColor: Colors.blue,
todayBorderWidth: 2.0,
// Selection
selectedDayBackground: Colors.blue.withValues(alpha: 0.1),
// Animation
animationDuration: Duration(milliseconds: 650),
animationCurve: Curves.decelerate,
pageTurnStyle: PageTurnStyle(
shadowOpacity: 0.8,
curlIntensity: 1.2,
),
// Gestures
flickDistanceThreshold: 0.05,
dragProgressThreshold: 0.3,
),
// ...
)
Theme Presets
// Light theme (same as default)
FlipCalendar(
controller: controller,
style: CalendarStyle.light(),
// ...
)
// Dark theme
FlipCalendar(
controller: controller,
style: CalendarStyle.dark(),
// ...
)
Using copyWith
final customStyle = CalendarStyle.dark().copyWith(
borderRadius: BorderRadius.circular(16),
animationDuration: Duration(milliseconds: 800),
todayBorderColor: Colors.amber,
);
Style Properties
Background & Layout
| Property | Type | Default | Description |
|---|---|---|---|
calendarBackground |
Color |
Colors.white |
Background color of the calendar widget |
padding |
EdgeInsets |
EdgeInsets.zero |
Padding inside the calendar background |
borderRadius |
BorderRadius |
BorderRadius.zero |
Border radius for the calendar's outer edges |
Grid
| Property | Type | Default | Description |
|---|---|---|---|
gridLineColor |
Color |
Color(0xFFE0E0E0) |
Color of grid lines between cells |
gridLineWidth |
double |
1.0 |
Width of grid lines |
Weekday Header
| Property | Type | Default | Description |
|---|---|---|---|
weekdayHeaderBackground |
Color |
Color(0xFFF5F5F5) |
Background color of the header row |
weekdayHeaderTextColor |
Color |
Colors.black87 |
Text color for weekday names |
weekdayHeaderHeight |
double |
40.0 |
Height of the header row |
weekdayTextStyle |
TextStyle? |
null |
Custom text style (overrides color/size) |
weekdayNames |
List<String> |
['Sun', 'Mon', ...] |
Weekday names starting from Sunday |
Day Cells
| Property | Type | Default | Description |
|---|---|---|---|
dayTextColor |
Color |
Colors.black87 |
Text color for day numbers |
disabledDayTextColor |
Color |
Color(0xFF9E9E9E) |
Text color for disabled days |
dayTextSize |
double |
16.0 |
Font size for day numbers |
Today Indicator
| Property | Type | Default | Description |
|---|---|---|---|
todayBorderColor |
Color |
Colors.blue |
Border color for today's cell |
todayBorderWidth |
double |
2.0 |
Border width for today's cell |
todayBorderRadius |
BorderRadius |
Radius.circular(4) |
Border radius for today's cell |
todayMargin |
EdgeInsets |
EdgeInsets.all(2) |
Margin around today's cell |
Selection & Disabled
| Property | Type | Default | Description |
|---|---|---|---|
selectedDayBackground |
Color |
Colors.blue (10%) |
Background for the selected day |
disabledDateBackground |
Color |
Colors.grey (10%) |
Background for disabled dates |
Animation
| Property | Type | Default | Description |
|---|---|---|---|
animationDuration |
Duration |
650ms |
Duration of the page-turn animation |
animationCurve |
Curve |
Curves.decelerate |
Easing curve for animation |
pageTurnStyle |
PageTurnStyle |
PageTurnStyle() |
Style for the page curl effect |
Gestures
| Property | Type | Default | Description |
|---|---|---|---|
flickDistanceThreshold |
double |
0.05 |
Min distance fraction for a flick |
flickMaxDuration |
Duration |
500ms |
Max duration for a flick gesture |
dragBoxSizePercentage |
double |
0.7 |
Fraction of dimension = 100% progress |
dragProgressThreshold |
double |
0.3 |
Min progress to complete a transition |
Building a Header
The calendar widget renders only the grid — headers, titles, and navigation buttons are your responsibility. Here's a typical pattern:
class CalendarScreen extends StatefulWidget {
@override
State<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends State<CalendarScreen> {
final _controller = CalendarController(initialMonth: DateTime.now());
DateTime? _selectedDate;
@override
void initState() {
super.initState();
_controller.addListener(() => setState(() {}));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final month = _controller.currentMonth;
final monthName = _monthNames[month.month - 1];
return Column(
children: [
// Custom header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: _controller.isAnimating
? null
: _controller.previousMonth,
icon: Icon(Icons.chevron_left),
),
Text(
'$monthName ${month.year}',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
onPressed: _controller.isAnimating
? null
: _controller.nextMonth,
icon: Icon(Icons.chevron_right),
),
],
),
// Calendar
Expanded(
child: FlipCalendar(
controller: _controller,
selectedDate: _selectedDate,
onDayTap: (date) => setState(() => _selectedDate = date),
dayBuilder: (context, data) {
return Center(
child: Text(data.date.day.toString()),
);
},
),
),
],
);
}
static const _monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
}
Note how _controller.isAnimating is used to disable the navigation buttons during animation.
API Reference
FlipCalendar
The main calendar widget.
const FlipCalendar({
required CalendarController controller,
required Widget Function(BuildContext, CalendarDayData) dayBuilder,
DateTime? selectedDate,
void Function(DateTime)? onDayTap,
void Function(CalendarHapticType)? onHapticFeedback,
CalendarStyle style = const CalendarStyle(),
int firstDayOfWeek = DateTime.sunday,
DateConstraint? minDate,
DateConstraint? maxDate,
PageTurnEdge boundEdge = PageTurnEdge.top,
bool animationsEnabled = true,
bool gesturesEnabled = true,
int maxAnimatedMonthJump = 6,
MultiMonthAnimationMode multiMonthAnimationMode = MultiMonthAnimationMode.sequential,
})
CalendarController
Navigation state manager. Extends ChangeNotifier.
| Property/Method | Description |
|---|---|
currentMonth |
The currently displayed month |
isAnimating |
Whether a transition is in progress |
nextMonth() |
Navigate to the next month |
previousMonth() |
Navigate to the previous month |
goToMonth(DateTime) |
Navigate to a specific month |
goToToday() |
Navigate to the current month |
goToYearMonth(int, int) |
Navigate to a specific year and month |
CalendarDayData
Data object passed to the day builder.
| Property | Type | Description |
|---|---|---|
date |
DateTime |
The date this cell represents |
isCurrentMonth |
bool |
Whether the date is in the displayed month |
isToday |
bool |
Whether the date is today |
isSelected |
bool |
Whether the date matches selectedDate |
isFutureDate |
bool |
Whether the date is after today |
isEnabled |
bool |
Whether the date is within min/max bounds |
row |
int |
Row index in the grid (0-based) |
column |
int |
Column index in the grid (0-based) |
DateConstraint
Defines a date boundary. Supports static, dynamic, and relative dates.
| Factory | Description |
|---|---|
DateConstraint.fixed(DateTime) |
A fixed date boundary |
DateConstraint.today() |
Today's date, recomputed each build |
DateConstraint.relative({years, months, days}) |
Offset from today, recomputed each build |
MultiMonthAnimationMode
| Value | Description |
|---|---|
sequential |
Flips through each intermediate month |
directJump |
Single flip from start to end month |
CalendarHapticType
| Value | Description |
|---|---|
navigationRestricted |
User swiped toward a restricted month |
CalendarStyle
Configuration class for visual styling. See Customization for all properties.
MonthGrid
Utility class for computing the grid layout of a month. Useful for building custom overlays or layouts on top of the calendar.
final grid = MonthGrid.forMonth(
DateTime(2025, 6, 1),
firstDayOfWeek: DateTime.monday,
);
print(grid.rows); // 5 or 6
print(grid.start); // First date shown in the grid
print(grid.dateAt(0, 0)); // Date at row 0, column 0
print(grid.totalCells); // rows * 7
Best Practices
Controller Lifecycle
Always dispose the controller when the parent widget is disposed:
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Performance Tips
- Use
CalendarStyle.pageTurnStyleto lowersegments(e.g., 50–80) on lower-end devices - Avoid rebuilding the parent widget during animations — use
_controller.isAnimatingto gate updates - For large month jumps,
MultiMonthAnimationMode.directJumpis more performant thansequential
Sizing
The calendar expands to fill available space. Wrap it in a SizedBox or use Expanded to control dimensions:
SizedBox(
height: 400,
child: FlipCalendar(
controller: controller,
dayBuilder: (context, data) => Center(
child: Text(data.date.day.toString()),
),
),
)
License
MIT License — see LICENSE for details.
Contributing
Contributions are welcome! Please feel free to submit issues and pull requests on GitHub.
Libraries
- flip_calendar
- A customizable month calendar widget with page-turn animations and swipe gesture navigation.



