Fliin
On this page

Blocks / commerce

Plan card

Presents a subscription plan to pick among others, as a radio button: name, billed price and period, trial, perks, and the recommended and current plan marks.

16 of 16 variants pass
Plan card, selected state
Platform
Brightness
Text scale
Direction

Import

Plan card is part of Fliin Pro. With a license key, sign in once with fliin login, then add it in a Flutter app where fliin init has run:

fliin login
fliin add plan-card

Also installs: core, chip, icons, radio, surface, text

Examples

Selected

Plans as a radio group: FlRadioGroup holds the choice and each FlPlanCard is one option, with its price, period and benefits. Tap a card to choose it.

Plan card, selected state
import 'package:your_app/ui/fliin/components/plan_card/fl_plan_card.dart';
import 'package:your_app/ui/fliin/components/radio/fl_radio.dart';
import 'package:flutter/widgets.dart';

class PlanCardSelectedExample extends StatefulWidget {
  const PlanCardSelectedExample({super.key});

  @override
  State<PlanCardSelectedExample> createState() => _PlanCardSelectedExampleState();
}

class _PlanCardSelectedExampleState extends State<PlanCardSelectedExample> {
  var _chosen = 'pro';

  @override
  Widget build(BuildContext context) {
    return FlRadioGroup<String>(
      groupValue: _chosen,
      onChanged: (value) => setState(() => _chosen = value ?? _chosen),
      children: const [
        FlPlanCard<String>(
          value: 'plus',
          name: 'Plus',
          price: '€49.99',
          period: 'per year',
          priceDetail: 'that’s €4.17 a month',
          benefits: ['Sync across 3 devices', '30-day history'],
        ),
        FlPlanCard<String>(
          value: 'pro',
          name: 'Pro',
          price: '€99.99',
          period: 'per year',
          priceDetail: '7 days free, then €99.99 a year',
          benefits: ['Unlimited devices', 'PDF and CSV export'],
          recommendedLabel: 'Recommended',
        ),
      ],
    );
  }
}

Unselected

The same group with the other plan chosen: the card that is not selected stays readable, with a quieter border.

Plan card, unselected state
import 'package:your_app/ui/fliin/components/plan_card/fl_plan_card.dart';
import 'package:your_app/ui/fliin/components/radio/fl_radio.dart';
import 'package:flutter/widgets.dart';

class PlanCardUnselectedExample extends StatefulWidget {
  const PlanCardUnselectedExample({super.key});

  @override
  State<PlanCardUnselectedExample> createState() => _PlanCardUnselectedExampleState();
}

class _PlanCardUnselectedExampleState extends State<PlanCardUnselectedExample> {
  var _chosen = 'plus';

  @override
  Widget build(BuildContext context) {
    return FlRadioGroup<String>(
      groupValue: _chosen,
      onChanged: (value) => setState(() => _chosen = value ?? _chosen),
      children: const [
        FlPlanCard<String>(
          value: 'plus',
          name: 'Plus',
          price: '€49.99',
          period: 'per year',
          priceDetail: 'that’s €4.17 a month',
          benefits: ['Sync across 3 devices', '30-day history'],
        ),
        FlPlanCard<String>(
          value: 'pro',
          name: 'Pro',
          price: '€99.99',
          period: 'per year',
          priceDetail: '7 days free, then €99.99 a year',
          benefits: ['Unlimited devices', 'PDF and CSV export'],
          recommendedLabel: 'Recommended',
        ),
      ],
    );
  }
}

recommendedLabel puts a badge on the plan you want to push; give it to one plan at most.

Plan card, recommended state
import 'package:your_app/ui/fliin/components/plan_card/fl_plan_card.dart';
import 'package:your_app/ui/fliin/components/radio/fl_radio.dart';
import 'package:flutter/widgets.dart';

class PlanCardRecommendedExample extends StatefulWidget {
  const PlanCardRecommendedExample({super.key});

  @override
  State<PlanCardRecommendedExample> createState() => _PlanCardRecommendedExampleState();
}

class _PlanCardRecommendedExampleState extends State<PlanCardRecommendedExample> {
  var _chosen = 'pro';

  @override
  Widget build(BuildContext context) {
    return FlRadioGroup<String>(
      groupValue: _chosen,
      onChanged: (value) => setState(() => _chosen = value ?? _chosen),
      children: const [
        FlPlanCard<String>(
          value: 'plus',
          name: 'Plus',
          price: '€49.99',
          period: 'per year',
          priceDetail: 'that’s €4.17 a month',
          benefits: ['Sync across 3 devices', '30-day history'],
        ),
        FlPlanCard<String>(
          value: 'pro',
          name: 'Pro',
          price: '€99.99',
          period: 'per year',
          priceDetail: '7 days free, then €99.99 a year',
          benefits: ['Unlimited devices', 'PDF and CSV export'],
          recommendedLabel: 'Recommended',
        ),
      ],
    );
  }
}

Current

currentLabel marks the plan the user already has, so choosing reads as a change rather than a purchase.

Plan card, current state
import 'package:your_app/ui/fliin/components/plan_card/fl_plan_card.dart';
import 'package:your_app/ui/fliin/components/radio/fl_radio.dart';
import 'package:flutter/widgets.dart';

class PlanCardCurrentExample extends StatefulWidget {
  const PlanCardCurrentExample({super.key});

  @override
  State<PlanCardCurrentExample> createState() => _PlanCardCurrentExampleState();
}

class _PlanCardCurrentExampleState extends State<PlanCardCurrentExample> {
  var _chosen = 'plus';

  @override
  Widget build(BuildContext context) {
    return FlRadioGroup<String>(
      groupValue: _chosen,
      onChanged: (value) => setState(() => _chosen = value ?? _chosen),
      children: const [
        FlPlanCard<String>(
          value: 'plus',
          name: 'Plus',
          price: '€49.99',
          period: 'per year',
          priceDetail: 'that’s €4.17 a month',
          benefits: ['Sync across 3 devices', '30-day history'],
          currentLabel: 'Current plan',
        ),
        FlPlanCard<String>(
          value: 'pro',
          name: 'Pro',
          price: '€99.99',
          period: 'per year',
          priceDetail: '7 days free, then €99.99 a year',
          benefits: ['Unlimited devices', 'PDF and CSV export'],
          recommendedLabel: 'Recommended',
        ),
      ],
    );
  }
}

Compact

A paywall's plans on one line each with FlPlanCardLayout.compact: the name and its detail at the start, the price at the end. savingsLabel says what the yearly plan saves.

Plan card, compact state
import 'package:your_app/ui/fliin/components/plan_card/fl_plan_card.dart';
import 'package:your_app/ui/fliin/components/radio/fl_radio.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class PlanCardCompactExample extends StatefulWidget {
  const PlanCardCompactExample({super.key});

  @override
  State<PlanCardCompactExample> createState() => _PlanCardCompactExampleState();
}

class _PlanCardCompactExampleState extends State<PlanCardCompactExample> {
  var _chosen = 'yearly';

  @override
  Widget build(BuildContext context) {
    final gap = SizedBox(height: FlScope.of(context).space.md);
    return FlRadioGroup<String>(
      groupValue: _chosen,
      onChanged: (value) => setState(() => _chosen = value ?? _chosen),
      children: [
        const FlPlanCard<String>(
          value: 'yearly',
          layout: FlPlanCardLayout.compact,
          name: 'Yearly',
          price: '€59.99',
          period: 'per year',
          priceDetail: 'Just €5.00 a month',
          savingsLabel: 'Save 17%',
        ),
        gap,
        const FlPlanCard<String>(
          value: 'monthly',
          layout: FlPlanCardLayout.compact,
          name: 'Monthly',
          price: '€5.99',
          period: 'per month',
          priceDetail: 'Cancel anytime',
        ),
      ],
    );
  }
}

Usage

The code the reference images render:

Localizations(locale: Locale'en', delegates: const [DefaultWidgetsLocalizations.delegate], child: ColoredBox(color: FlScope.of(context).colors.surface, child: Padding(padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, spacing: FlScope.of(context).space.xl, children: [FlRadioGroup<String>(groupValue: 'pro', onChanged: (_) {}, children: [FlPlanCard<String>(value: 'plus', name: 'Plus', price: (variant.dir == TextDirection.rtl ? "4٫99 €" : "€4.99"), period: (variant.dir == TextDirection.rtl ? "شهريًا" : "per month"), benefits: [(variant.dir == TextDirection.rtl ? "مزامنة على 3 أجهزة" : "Sync across 3 devices"), (variant.dir == TextDirection.rtl ? "سجل 30 يومًا" : "30-day history")], currentLabel: (variant.dir == TextDirection.rtl ? "خطتك الحالية" : "Current plan")), FlPlanCard<String>(value: 'pro', name: 'Pro', price: (variant.dir == TextDirection.rtl ? "99٫99 €" : "€99.99"), period: (variant.dir == TextDirection.rtl ? "سنويًا" : "per year"), priceDetail: (variant.dir == TextDirection.rtl ? "مجانًا لمدة 7 أيام، ثم 99٫99 € سنويًا\nأي 8٫33 € شهريًا" : "7 days free, then €99.99 a year\nthat’s €8.33 a month"), benefits: [(variant.dir == TextDirection.rtl ? "أجهزة غير محدودة" : "Unlimited devices"), (variant.dir == TextDirection.rtl ? "تصدير PDF وCSV" : "PDF and CSV export"), (variant.dir == TextDirection.rtl ? "دعم ذو أولوية" : "Priority support")], recommendedLabel: (variant.dir == TextDirection.rtl ? "موصى بها" : "Recommended"))])]))))

API

FlPlanCardLayout: enum {full, compact}
FlPlanCardLayout.full: static final FlPlanCardLayout
FlPlanCardLayout.compact: static final FlPlanCardLayout
FlPlanCard: class<T> extends StatelessWidget
FlPlanCard.new: const new({super.key, required T value, required String name, required String price, required String period, String? priceDetail, String? description, List<String> benefits, String? recommendedLabel, String? currentLabel, bool enabled, FlPlanCardLayout layout, String? savingsLabel})
FlPlanCard.value: get T
FlPlanCard.name: get String
FlPlanCard.price: get String
FlPlanCard.period: get String
FlPlanCard.priceDetail: get String?
FlPlanCard.description: get String?
FlPlanCard.benefits: get List<String>
FlPlanCard.recommendedLabel: get String?
FlPlanCard.currentLabel: get String?
FlPlanCard.enabled: get bool
FlPlanCard.layout: get FlPlanCardLayout
FlPlanCard.savingsLabel: get String?
FlPlanCard.build: Widget(BuildContext context)

Reference renders

The 16 images published with version 0.2.2, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify plan-card compares your copy to them.

iOS · Light
Plan card, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Plan card, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Plan card, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Plan card, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Plan card, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Plan card, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Plan card, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Plan card, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Plan card, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Plan card, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Plan card, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Plan card, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Plan card, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Plan card, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Plan card, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Plan card, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • present a subscription tier with its price and perks
  • make the recommended tier stand out among several
  • make clear which tier the user already has
  • show how long the free trial lasts and the price after it
  • pick a pricing tier in a subscription sheet
Not for
  • a short choice without a price: use radio buttons (radio)
  • the whole plans screen: use the plans screen (plans)
States
  • Selected
  • Unselected
  • Recommended
  • Current

Arrows to move, Enter to open, Escape to close.