When building a food delivery app last year, I needed those little +/- buttons you see in every shopping cart. You know—tap minus, quantity drops; tap plus, it goes up. Simple, right?

I spent an hour searching pub.dev for the perfect package. Most were abandoned, bloated, or impossible to style. So I built my own in 50 lines of code.

Best hour I spent that month.

Here’s how to build a clean, customizable quantity selector widget in Flutter that does exactly what you need.

What is a Quantity Widget?

A quantity widget (also called a stepper or quantity selector) is a UI component that lets users adjust numeric values with buttons instead of typing. It typically has three parts:

  • Minus button (−) on the left
  • Current number in the middle
  • Plus button (+) on the right

You see them everywhere:

E-commerce apps → Adjusting cart quantities (“I want 3 shirts”)
Food delivery → Adding items (“Make that 2 burgers”)
Booking apps → Selecting guests or tickets (“4 adults, 2 children”)
Inventory systems → Stock adjustments (“50 units received”)

Why use buttons instead of a text field? Because tapping +/- is way faster on mobile than selecting text, clearing it, and typing a new number. Better UX, fewer errors.

Why Build a Custom Widget?

Packages exist, but here’s why I build custom:

 Full control – Style it exactly how you want
 Lightweight – 50 lines vs hundreds from a package
 No dependencies – No breaking changes or abandoned maintainers
 Learning – Understanding [stateful widgets](link to “Stateful vs Stateless Widgets”) is crucial for Flutter

Use a package if:

  • You need complex features (haptics, animations) immediately
  • You’re prototyping and don’t care about styling
  • You want battle-tested edge case handling

For most apps? Custom is cleaner.

The Complete Code

Here’s the full implementation:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Quantity Widget Demo')),
        body: Center(
          child: QuantityWidget(
            initialValue: 1,
            minValue: 1,
            maxValue: 10,
            onChanged: (value) => print("Quantity: $value"),
          ),
        ),
      ),
    );
  }
}

class QuantityWidget extends StatefulWidget {
  final int initialValue;
  final int minValue;
  final int maxValue;
  final Function(int) onChanged;

  const QuantityWidget({
    Key? key,
    this.initialValue = 1,
    this.minValue = 1,
    this.maxValue = 99,
    required this.onChanged,
  }) : super(key: key);

  @override
  _QuantityWidgetState createState() => _QuantityWidgetState();
}

class _QuantityWidgetState extends State<QuantityWidget> {
  late int _value;

  @override
  void initState() {
    super.initState();
    _value = widget.initialValue;
  }

  void _increment() {
    if (_value < widget.maxValue) {
      setState(() => _value++);
      widget.onChanged(_value);
    }
  }

  void _decrement() {
    if (_value > widget.minValue) {
      setState(() => _value--);
      widget.onChanged(_value);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        IconButton(
          icon: Icon(Icons.remove_circle_outline),
          onPressed: _value > widget.minValue ? _decrement : null,
          color: Colors.red,
        ),
        Padding(
          padding: EdgeInsets.symmetric(horizontal: 12),
          child: Text(
            '$_value',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
        ),
        IconButton(
          icon: Icon(Icons.add_circle_outline),
          onPressed: _value < widget.maxValue ? _increment : null,
          color: Colors.green,
        ),
      ],
    );
  }
}

How It Works

Why StatefulWidget?
The quantity changes based on user interaction, so we need [state management](link to “Stateful vs Stateless Widgets”). The widget “remembers” the current number and updates the UI when it changes.

The late keyword:
Tells Dart “I’ll initialize this before using it.” We set _value in initState() before the widget builds.

setState() is crucial:
Without it, _value changes internally but the UI doesn’t update. This is a common beginner mistake.

The callback pattern:
onChanged lets parent widgets know when quantity changes. Your cart can listen and recalculate totals. This is standard Flutter child-to-parent communication.

Conditional buttons:
Setting onPressed: null disables the button. When you hit min/max, buttons gray out automatically.

Styling Options

The basic version works but looks plain. Here are three quick styles:

Bordered Style

Container(
  decoration: BoxDecoration(
    border: Border.all(color: Colors.grey.shade300),
    borderRadius: BorderRadius.circular(8),
  ),
  child: Row(
    mainAxisSize: MainAxisSize.min,
    children: [
      IconButton(
        icon: Icon(Icons.remove),
        onPressed: _value > widget.minValue ? _decrement : null,
      ),
      Padding(
        padding: EdgeInsets.symmetric(horizontal: 16),
        child: Text('$_value', style: TextStyle(fontSize: 18)),
      ),
      IconButton(
        icon: Icon(Icons.add),
        onPressed: _value < widget.maxValue ? _increment : null,
      ),
    ],
  ),
)

Filled Background

Container(
  decoration: BoxDecoration(
    color: Colors.grey.shade100,
    borderRadius: BorderRadius.circular(12),
  ),
  padding: EdgeInsets.all(4),
  child: Row(
    mainAxisSize: MainAxisSize.min,
    children: [
      IconButton(
        icon: Icon(Icons.remove, size: 20),
        onPressed: _decrement,
        constraints: BoxConstraints(),
      ),
      Container(
        width: 40,
        alignment: Alignment.center,
        child: Text('$_value', style: TextStyle(fontWeight: FontWeight.bold)),
      ),
      IconButton(
        icon: Icon(Icons.add, size: 20),
        onPressed: _increment,
        constraints: BoxConstraints(),
      ),
    ],
  ),
)

Modern Circular

Row(
  mainAxisSize: MainAxisSize.min,
  children: [
    Container(
      decoration: BoxDecoration(
        color: Colors.red.shade50,
        shape: BoxShape.circle,
      ),
      child: IconButton(
        icon: Icon(Icons.remove, color: Colors.red),
        onPressed: _decrement,
      ),
    ),
    Padding(
      padding: EdgeInsets.symmetric(horizontal: 20),
      child: Text('$_value', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
    ),
    Container(
      decoration: BoxDecoration(
        color: Colors.green.shade50,
        shape: BoxShape.circle,
      ),
      child: IconButton(
        icon: Icon(Icons.add, color: Colors.green),
        onPressed: _increment,
      ),
    ),
  ],
)

Pick what matches your app’s design. The core logic stays the same—only the build() method changes.

Real-World Usage Example

Here’s how to use it in a shopping cart:

class CartItem extends StatefulWidget {
  final String productName;
  final double price;

  CartItem({required this.productName, required this.price});

  @override
  _CartItemState createState() => _CartItemState();
}

class _CartItemState extends State<CartItem> {
  int quantity = 1;
  
  double get total => widget.price * quantity;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(widget.productName),
      subtitle: Text('\$${widget.price.toStringAsFixed(2)} each'),
      trailing: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          QuantityWidget(
            initialValue: quantity,
            minValue: 1,
            maxValue: 99,
            onChanged: (value) {
              setState(() => quantity = value);
            },
          ),
          Text('Total: \$${total.toStringAsFixed(2)}'),
        ],
      ),
    );
  }
}

When quantity changes, the total updates automatically. Simple and effective.

Common Problems & Solutions

Problem 1: Value doesn’t update in parent widget

Make sure you’re calling setState() in the parent:

QuantityWidget(
  onChanged: (newValue) {
    setState(() {
      cartItem.quantity = newValue; // Must use setState
    });
  },
)

Problem 2: Widget looks weird on tablets

Use LayoutBuilder for responsive sizing:

LayoutBuilder(
  builder: (context, constraints) {
    double size = constraints.maxWidth > 600 ? 48 : 40;
    // Use size for buttons
  },
)

Problem 3: Need haptic feedback

Add this import and update your methods:

import 'package:flutter/services.dart';

void _increment() {
  if (_value < widget.maxValue) {
    HapticFeedback.lightImpact();
    setState(() => _value++);
    widget.onChanged(_value);
  }
}

Learn more about [handling user interaction in Flutter](link to “User Interaction & Input”).

Problem 4: Initial value doesn’t match async data

Use null-aware operators:

QuantityWidget(
  initialValue: cartData?.quantity ?? 1,
  // ...
)

Adding Animation (Optional)

Want the number to scale when it changes? Use AnimatedSwitcher:

AnimatedSwitcher(
  duration: Duration(milliseconds: 200),
  transitionBuilder: (child, animation) {
    return ScaleTransition(scale: animation, child: child);
  },
  child: Text(
    '$_value',
    key: ValueKey<int>(_value), // Required for animation
    style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
  ),
)

Small touch, big impact on UX.

State Management Integration

If you’re using Provider or Riverpod:

QuantityWidget(
  initialValue: context.watch<CartProvider>().getQuantity(productId),
  onChanged: (value) {
    context.read<CartProvider>().updateQuantity(productId, value);
  },
)

Want to learn state management? Check out our [Calculator App with Provider tutorial](link to “Build a Flutter Calculator App with Provider”).

Accessibility

Don’t forget users with accessibility needs:

IconButton(
  icon: Icon(Icons.add),
  onPressed: _increment,
  tooltip: 'Increase quantity',
  semanticsLabel: 'Increase quantity by one',
)

The tooltip shows on long press, and semanticsLabel helps screen readers. See Flutter’s accessibility guide for more.

Best Practices

Define min/max values – Prevent invalid quantities
Make it responsive – Test on different screen sizes
Add visual feedback – Show when limits are reached
Consider haptics – Physical feedback improves UX
Use proper callbacks – Let parent widgets react to changes

Performance Note

This widget is lightweight. The setState() call only rebuilds the QuantityWidget, not your entire screen. For large lists with many quantity widgets, wrap each in RepaintBoundary:

RepaintBoundary(
  child: QuantityWidget(...),
)

Learn more about Flutter performance optimization.

The Bottom Line

A quantity selector seems simple until you need one. This 50-line widget:

  • Works reliably out of the box
  • Handles edge cases (min/max limits)
  • Integrates with any state management
  • Can be styled to match any design
  • Performs well even in large lists

You could spend time finding and configuring a package, or paste this code and keep building your app.

What’s Next?

Now that you have a reusable widget, explore:

  1. Master widgets – Understanding Flutter Widgets teaches widget composition
  2. Learn layouts – Flutter Layouts Guide shows how to position widgets
  3. Build projects – Try our 10 Beginner Flutter Projects
  4. Avoid mistakes – Check Top 7 Flutter Mistakes Beginners Make
  5. Understand state – Our Stateful vs Stateless guide is essential reading

Questions about implementing this in your app? Drop a comment below and I’ll help you out.

Now go build something cool.

Leave a Reply

Your email address will not be published. Required fields are marked *