Flutter Motion Kit

Previewable Flutter animations + pitfall guide · one-click reuse from Claude Code

← Back

3D card flip

explicit · difficulty 3/5 · verified Flutter 3.32 / 2026-06

Tap a card to flip it 180° around the Y axis and reveal the back. An AnimationController drives an AnimatedBuilder + Transform whose Matrix4 carries a perspective term so the rotation reads as a real card, not a flat squash.

▶︎ Running for real (Flutter 3.32 · self-hosted, not a recording)

Code

// ✅ Recommended: AnimationController + AnimatedBuilder + Transform with a real
// perspective term, the back face counter-rotated, and proper dispose().
// Paste straight into DartPad (https://dartpad.dev) to run.
import 'dart:math';

import 'package:flutter/material.dart';

void main() => runApp(const _App());

class _App extends StatelessWidget {
  const _App();
  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
    home: const _Demo(),
  );
}

class _Demo extends StatefulWidget {
  const _Demo();
  @override
  State<_Demo> createState() => _DemoState();
}

// Single controller → SingleTickerProviderStateMixin
class _DemoState extends State<_Demo> with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 500),
  );

  void _toggle() {
    // Flip toward whichever face is currently hidden.
    if (_controller.value < 0.5) {
      _controller.forward();
    } else {
      _controller.reverse();
    }
  }

  @override
  void dispose() {
    _controller.dispose(); // ✅ release the Ticker to prevent leaks
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('3D card flip')),
      body: Center(
        child: GestureDetector(
          onTap: _toggle,
          child: AnimatedBuilder(
            animation: _controller,
            builder: (context, _) {
              // 0 → 0.5 shows the front, 0.5 → 1 shows the back.
              final angle = _controller.value * pi;
              final showFront = _controller.value < 0.5;
              return Transform(
                alignment: Alignment.center, // ✅ flip about the card's center
                // ✅ perspective term gives the rotation real depth
                transform: Matrix4.identity()
                  ..setEntry(3, 2, 0.0015)
                  ..rotateY(angle),
                child: showFront
                    ? const _CardFace(
                        color: Colors.indigo,
                        label: 'FRONT',
                        icon: Icons.credit_card,
                      )
                    // ✅ counter-rotate the back so its content isn't mirrored
                    : Transform(
                        alignment: Alignment.center,
                        transform: Matrix4.rotationY(pi),
                        child: const _CardFace(
                          color: Colors.teal,
                          label: 'BACK',
                          icon: Icons.qr_code_2,
                        ),
                      ),
              );
            },
          ),
        ),
      ),
    );
  }
}

class _CardFace extends StatelessWidget {
  const _CardFace({
    required this.color,
    required this.label,
    required this.icon,
  });

  final Color color;
  final String label;
  final IconData icon;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 220,
      height: 140,
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(16),
        boxShadow: const [
          BoxShadow(
            color: Colors.black26,
            blurRadius: 12,
            offset: Offset(0, 6),
          ),
        ],
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(icon, size: 48, color: Colors.white),
          const SizedBox(height: 12),
          Text(
            label,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 20,
              fontWeight: FontWeight.bold,
              letterSpacing: 2,
            ),
          ),
        ],
      ),
    );
  }
}

⚠️ Pitfalls (4)

Without a perspective term the rotation looks flat — the card just squashes horizontally instead of turning in depth.
✅ Seed the matrix with perspective before rotating, e.g. Matrix4.identity()..setEntry(3, 2, 0.0015)..rotateY(angle).
community-consensus · source
The back face renders mirrored, so its text/content reads backwards once the flip passes 90°.
✅ Counter-rotate the back child by pi (Transform with Matrix4.rotationY(pi)) so it un-mirrors after the parent flip.
author-experience
Swapping front/back at the wrong moment shows the wrong face through the card edge.
✅ Switch from front to back exactly when controller.value crosses 0.5 (the 90° midpoint), where the card is edge-on.
community-consensus
Omitting Transform alignment or forgetting to dispose the controller off-centers the flip and leaks the Ticker.
✅ Set alignment Alignment.center on the Transform and call _controller.dispose() in State.dispose().
official-docs · source

Official docs