🧩 Widget Fundamentals — Build Your Own Custom Widgets
🧒 What Are Widgets? (The LEGO Analogy Returns!)
Widgets Are Like LEGO Bricks — But Smarter!
In the last lesson, you learned that everything in Flutter is a widget. Now you're going to learn how to create your own custom widgets — just like designing your own special LEGO piece that you can reuse over and over!
Today, you'll build a "Tile" widget for the Birdle game — a colored square that shows a letter and changes color based on whether the guess is correct (green 🟩), partially correct (yellow 🟨), or wrong (gray ⬜).
- ✅ Create a custom
StatelessWidgetfrom scratch - ✅ Use constructor parameters to make widgets reusable
- ✅ Style widgets with
ContainerandBoxDecoration - ✅ Understand how data flows into widgets
The Anatomy of Every Widget
Every custom widget you create has three essential parts:
1. Constructor
The "delivery address" — this is where you tell the widget what data it needs. Like ordering a pizza: "I want a large pepperoni!" 🍕
2. Properties (Fields)
The "storage closet" — the widget remembers the data you gave it so it can use it later when drawing itself.
3. Build Method
The "artist studio" — this is where the widget actually draws itself on screen. Every widget MUST have this!
Container + BoxDecoration = Beautiful Widgets!
Think of a Container like a gift box 📦. It has:
- Size — how big the box is (width & height)
- Decoration — the wrapping paper and ribbon (BoxDecoration)
- Child — what's inside the box (another widget!)
BoxDecoration is the "wrapping paper" — it controls borders, background colors, shadows, and rounded corners. Together, they make your widgets look professional!
0 Before You Start — Add the Game Logic File
Before we build the UI, we need to add a file that handles the game logic (the behind-the-scenes rules for Birdle). This file does all the thinking — our widgets will just display the results.
Step 0.1 Download the Game Logic File
Download this Dart file and save it as lib/game.dart inside your Birdle project folder.
This file contains all the rules for the game — how guesses are checked, what words are valid,
and how scoring works.
- Make sure your Birdle project is open in VS Code
- In the file explorer (left sidebar), right-click on the
libfolder - Select "New File"
- Name it
game.dart - Copy and paste the game logic code into this file
- Save the file (Ctrl+S / Cmd+S)
📄 Click to view the full game.dart code (it's long — you just need to copy-paste it)
/// Game logic and supporting types for Birdle
library;
import 'dart:collection';
import 'dart:math';
enum HitType { none, hit, partial, miss }
typedef Letter = ({String char, HitType type});
const List<String> legalWords = ['aback', 'abase', 'abate', 'abbey', 'abbot'];
const List<String> legalGuesses = ['aback', 'abase', 'abate', 'abbey', 'abbot', 'abhor', 'abide', 'abled', 'abode', 'abort'];
const List<String> allLegalGuesses = [...legalWords, ...legalGuesses];
class Game {
static const int defaultMaxGuesses = 5;
Game({this.maxGuesses = defaultMaxGuesses, this.seed})
: _wordToGuess = _generateInitialWord(seed),
_guesses = List<Word>.filled(maxGuesses, Word.empty());
final int maxGuesses;
final int? seed;
Word _wordToGuess;
List<Word> _guesses;
Word get hiddenWord => _wordToGuess;
UnmodifiableListView<Word> get guesses => UnmodifiableListView(_guesses);
Word get previousGuess {
final index = _guesses.lastIndexWhere((word) => word.isNotEmpty);
return index == -1 ? Word.empty() : _guesses[index];
}
int get activeIndex => _guesses.indexWhere((word) => word.isEmpty);
int get guessesRemaining => activeIndex == -1 ? 0 : maxGuesses - activeIndex;
bool get didWin {
if (_guesses.first.isEmpty) return false;
for (final letter in previousGuess) {
if (letter.type != HitType.hit) return false;
}
return true;
}
bool get didLose => guessesRemaining == 0 && !didWin;
void resetGame() {
_wordToGuess = _generateInitialWord(seed);
_guesses = List<Word>.filled(maxGuesses, Word.empty());
}
Word guess(String guess) {
final result = matchGuessOnly(guess);
addGuessToList(result);
return result;
}
bool isLegalGuess(String guess) => Word.fromString(guess).isLegalGuess;
Word matchGuessOnly(String guess) => Word.fromString(guess).evaluateGuess(_wordToGuess);
void addGuessToList(Word guess) {
final guessIndex = activeIndex;
if (guessIndex == -1) throw StateError('No guesses remaining.');
_guesses[guessIndex] = guess;
}
static Word _generateInitialWord(int? seed) =>
seed == null ? Word.random() : Word.fromSeed(seed);
}
class Word with IterableMixin<Letter> {
Word(this._letters);
factory Word.empty() => Word(List<Letter>.filled(5, (char: '', type: HitType.none)));
factory Word.fromString(String guess) { /* ... */ }
factory Word.random() { /* ... */ }
factory Word.fromSeed(int seed) => Word.fromString(legalWords[seed % legalWords.length]);
final List<Letter> _letters;
@override Iterator<Letter> get iterator => _letters.iterator;
@override bool get isEmpty => every((letter) => letter.char.isEmpty);
@override int get length => _letters.length;
Letter operator [](int i) => _letters[i];
@override String toString() => _letters.map((l) => l.char).join().trim();
}
extension WordUtils on Word {
bool get isLegalGuess => allLegalGuesses.contains(toString());
Word evaluateGuess(Word hiddenWord) { /* scoring logic */ }
}
Note: The actual file is longer — you can copy the full code from the official Flutter tutorial ↗.
Step 0.2 Import the Game Logic in main.dart
Now tell your main.dart file to use the game logic by adding an import at the top:
import 'package:flutter/material.dart';
import 'game.dart'; // ← Add this line!
This is like telling Flutter: "Hey, I'm going to use things from the game.dart file in this code!"
1 Create Your First Custom Widget — The Tile
Now the fun begins! You're going to create a brand new widget from scratch called Tile.
This widget will represent one square in the Birdle game grid.
Step 1.1 The Tile Class — Line by Line
Add this code below the MainApp class in your main.dart file:
🔍 Part 1: The Class Declaration and Constructor
class Tile extends StatelessWidget {
const Tile(this.letter, this.hitType, {super.key});
final String letter;
final HitType hitType;
@override
Widget build(BuildContext context) {
return Container();
}
}
What each line means in plain English:
class Tile extends StatelessWidget— "I'm creating a new widget type called Tile. It's stateless because once it's drawn, it doesn't change on its own."const Tile(this.letter, this.hitType, ...)— "When someone creates a Tile, they MUST give me a letter (like 'A') and a hitType (like 'correct' or 'wrong'). Thethis.letteris a shortcut — it automatically saves the value to a property."final String letter;— "I'll remember the letter they gave me in a variable called 'letter'. It'sfinalbecause it won't change."final HitType hitType;— "I'll also remember what kind of guess result this is (hit, partial, or miss)."build()— "When it's time to draw myself, here's what I look like." Currently it returns an emptyContainer— we'll fill it in soon!
2 Use Your Tile Widget in the App
Let's replace the text in your app with one Tile so you can see it as you build it.
Step 2.1 Display One Tile
In your MainApp.build method, replace the Text widget with a Tile:
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Tile('A', HitType.hit), // ← NEW: Using our custom widget!
),
),
);
}
}
Hot reload your app. You'll see... nothing? That's because our Tile currently returns an empty Container — a Container with nothing inside is invisible! Let's fix that next.
3 Give Your Tile Size with Container
A Container without a size is invisible. Let's give it width and height so we can see it!
Step 3.1 Set the Size
@override
Widget build(BuildContext context) {
return Container(
width: 60, // ← 60 pixels wide
height: 60, // ← 60 pixels tall (a square!)
);
}
Hot reload! Now you should see a white square on the screen. It's 60×60 pixels — about the size of a game tile.
4 Style Your Tile with BoxDecoration
A plain white square is boring. Let's add borders and colors using BoxDecoration —
the "wrapping paper" for your Container!
Step 4.1 Add a Border
return Container(
width: 60,
height: 60,
decoration: BoxDecoration( // ← NEW!
border: Border.all(color: Colors.grey.shade300), // ← Light gray border
),
);
Hot reload! Now your white square has a light gray border around it — starting to look like a real game tile!
Step 4.2 Add Smart Colors with Switch Expression
Now the cool part — make the tile change color automatically based on the hitType:
- 🟩 Hit (correct letter, correct position) → Green
- 🟨 Partial (correct letter, wrong position) → Yellow
- ⬜ Miss (wrong letter) → Gray
return Container(
width: 60,
height: 60,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
color: switch (hitType) { // ← Smart color picking!
HitType.hit => Colors.green, // Correct = Green
HitType.partial => Colors.yellow, // Partial = Yellow
HitType.miss => Colors.grey, // Miss = Gray
_ => Colors.white, // Default = White
},
),
);
hitType
value and says: "If it's a hit → use green. If it's a partial → use yellow. If it's a miss → use gray.
For anything else → use white." It's a cleaner way to write multiple if-else statements!
5 Put Content Inside — Center & Text
The tile has a size and color, but it's empty inside. Let's add the letter!
Step 5.1 Add a Child to Container
Add a child property to the Container with a Center and Text widget:
class Tile extends StatelessWidget {
const Tile(this.letter, this.hitType, {super.key});
final String letter;
final HitType hitType;
@override
Widget build(BuildContext context) {
return Container(
width: 60,
height: 60,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
color: switch (hitType) {
HitType.hit => Colors.green,
HitType.partial => Colors.yellow,
HitType.miss => Colors.grey,
_ => Colors.white,
},
),
child: Center( // ← Centers what's inside
child: Text( // ← Shows text
letter.toUpperCase(), // ← Makes 'a' → 'A'
style: Theme.of(context).textTheme.titleLarge, // ← Big, bold text
),
),
);
}
}
What's happening here:
Center()— Takes its child and puts it right in the middle (horizontally and vertically).Text(letter.toUpperCase())— Shows the letter, but makes it UPPERCASE. So 'a' becomes 'A'.Theme.of(context).textTheme.titleLarge— Uses the app's default large title text style (usually bold and around 20-22px).
Step 5.2 Experiment! Change the Colors
Hot reload, and you should see a green square with the letter 'A' in the center! Now try changing the hitType in MainApp:
Each time you change the hitType and hot reload, the tile changes color instantly. One widget, infinite possibilities!
📝 What You Learned Today
You just learned the core skill of Flutter development — creating custom widgets!
Built a Custom StatelessWidget
Created a Tile widget by extending StatelessWidget with a constructor, properties, and a build method.
Made Reusable Widgets
Used constructor parameters (letter and hitType) to make one Tile display different content and colors.
Styled with Container & BoxDecoration
Used Container for size, BoxDecoration for borders and colors, and a switch expression for smart color logic.
Composed Widgets Together
Built the widget tree: Container → Center → Text — nesting widgets inside each other to create the final UI.
🧠 Test Yourself!
Let's see what you remember! Answer these two questions:
Q1
What must every Flutter widget's build method return?
Q2 Which object is used to add decorations like borders and background colors to a Container?
📦 Project 1: Create a Colored Letter Display
Practice what you learned by creating a simple display of colored letter tiles.
Objective: Display 3 Different Tiles
📋 Requirements:
- In
MainApp, replace the singleCenterwith a Column containing 3 Tiles:- Tile 1: Letter 'F' with
HitType.hit(green) - Tile 2: Letter 'L' with
HitType.partial(yellow) - Tile 3: Letter 'Y' with
HitType.miss(gray)
- Tile 1: Letter 'F' with
- Add
SizedBox(height: 8)between each Tile to space them apart - Hot reload and verify all three colors appear
🎯 Expected Output:
Three colored squares stacked vertically with the letters F (green), L (yellow), and Y (gray).
mainAxisAlignment: MainAxisAlignment.center on the Column to center the tiles vertically on screen.
📦 Project 2: Create a "Guess This Word" Display
Now build a horizontal row of tiles to display a word — just like the Birdle game board!
Objective: Display a 5-Letter Word as Tiles
📋 Requirements:
- Create a Row with 5 Tiles for the word "FLUTTER" (use just 5 letters, e.g., "FLUTT")
- The first letter should be
HitType.hit(green) - The second letter should be
HitType.partial(yellow) - The remaining three letters should be
HitType.miss(gray) - Add
SizedBox(width: 4)between each Tile - Wrap the Row in a Column with a title text above it saying "Guess the word:"
🎯 Expected Output:
A title "Guess the word:" with a horizontal row of 5 colored tiles below it, showing F (green), L (yellow), U (gray), T (gray), T (gray).
- Use
Rowto arrange tiles horizontally - Use
Columnto stack the title text above the row - Remember:
children: [...]for multiple widgets inside Row/Column - Add
mainAxisAlignment: MainAxisAlignment.centerto center the row
Lesson Complete!
You built your first custom widget from scratch, learned about Container and BoxDecoration, and created reusable components. You're thinking like a real Flutter developer now!
Click the button above to track your progress!