- Design System
- Flutter
- Payments
Pay DS: payment components for Flutter, built for UPI
Payment successful
₹450
Paid to Priya Sharma
- To
- priya.sharma@upi
- From
- Northwind Bank ••4242
- UPI ref
- 4821 0937 5512
PdsPaymentStatusSuccess stateA payment is the one tap you can’t take back
Most UI forgives mistakes. You can undo a deleted email or edit a sent message. A UPI payment is different: once it leaves, it’s gone. That makes the payment screen the place where an interface has to be fastest, clearest and most trustworthy, all at once.
Pay DS is our answer to that. It is a Flutter package of design tokens, a Material 3 theme, payment components and GPU shader effects, built for Indian (UPI) payments. In its own words: payment components for Flutter, built on Material 3 and set in Plus Jakarta Sans.
The workflow is three steps: import one library, apply the theme, compose components. Because it is Flutter, the same components run on Android and iOS from one Dart codebase.
The catalogue lives in a Widgetbook (think Storybook, for Flutter). It lists 33 components and 98 use-cases, including docs and foundation pages, and 20 widgets with their own API pages. You can switch dark mode, change text size and even slow time down while you explore.
Why payments need their own design system
A generic component library gets you buttons and text fields. It doesn’t know that ₹1,23,456 is grouped differently from $123,456, that a pay button must never freeze, or that a celebration should only play when money actually moved. Pay DS is built on five pillars.
| Pillar | What it means in practice |
|---|---|
| Token-driven | Colour, type, spacing, radius, motion and springs come from one token layer on Material 3. A rebrand is a seed change. |
| Built for Indian payments | Lakh/crore grouping as you type, UPI limits, paise-precise amounts, tokenised cards and UPI receipts. |
| Motion with a reason | Fast ease-out for responses, springs for gestures, celebrations only where they’re rare. Exits are quicker than entrances. |
| Custom GPU shaders | Hand-written GLSL: warp tunnel, refractive shockwave, aurora, holographic foil and burn dissolve. They degrade to no effect and turn off under reduced motion. |
| Accessible by default | Semantic labels, live regions, a non-gesture path for every gesture, text scaling, and reduced motion that keeps fades but removes movement. |
Components are tagged Stable. The five shader effects are tagged Beta.
Foundations: one token layer
Every colour, space, corner and duration in Pay DS is a named token on top of Material 3. Change the seed colour and the whole system re-themes. Payment status colours ride along in the theme as a Flutter ThemeExtension, so context.pdsStatus.success works anywhere.
Primary
Secondary & tertiary
Surfaces
Payment status
Spacing PdsSpace
- xxs2
- xs4
- sm8
- md12
- lg16
- xl24
- xxl32
- xxxl48
Radius PdsRadius
- sm 8
- md 12
- lg 16
- card 20
- xl 28
Colour (light theme)
| Group | Tokens |
|---|---|
| Primary | primary #3B43D9 · primaryContainer #DFE0FF |
| Secondary & tertiary | secondaryContainer #CDE5FF · tertiary #A62C7A · tertiaryContainer #FFD8EC |
| Surfaces | surface #F9F9FF · surfaceContainerLow #F2F3FC · surfaceContainerHigh #E5E6F2 · surfaceContainerHighest #DFE0EC |
| Payment status | success #188038 · successContainer #C4EED0 · pendingContainer #FFE8A3 · error #BA1A1A · errorContainer #FFDAD6 |
Spacing and radius
Spacing sits on a 4dp grid (PdsSpace): xxs 2 · xs 4 · sm 8 · md 12 · lg 16 · xl 24 · xxl 32 · xxxl 48.
Corners (PdsRadius): sm 8 · md 12 · lg 16 · card 20 · xl 28.
The type scale uses Material 3 roles, from displayLarge down to labelSmall, all set in Plus Jakarta Sans.
Motion with a reason
Motion has one rule, written on the Motion page: responses stay under 300ms and enter on a strong ease-out. Exits are faster than entrances. Springs handle anything a finger can interrupt. Celebration is reserved for rare moments. Keyboard input never animates position. Reduced motion keeps fades and drops movement.
| Token | Duration |
|---|---|
PdsMotion.press | 120ms |
PdsMotion.short | 180ms |
PdsMotion.medium | 260ms |
PdsMotion.long | 420ms |
PdsMotion.extraLong | 700ms |
Curves are chosen by job: easeOut for entering and responding to input, easeInOut for moving between two on-screen positions, drawer for stacks and sheets, standard for cross-fades, and linear only for continuous motion like progress.
Two springs cover gestures. PdsMotion.snappy uses stiffness 520 and damping ratio 0.82. PdsMotion.playful uses stiffness 300 and damping ratio 0.58. A lower damping ratio means more bounce, so playful overshoots more than snappy.
Durations
PdsMotion.press120msPdsMotion.short180msPdsMotion.medium260msPdsMotion.long420msPdsMotion.extraLong700ms
Springs
Built for Indian payments
Pay DS stores every amount as a whole number of paise: 45000 means ₹450. Floating-point numbers can’t represent values like 0.1 exactly, so money code commonly uses whole minor units. Pay DS follows that best practice everywhere, so there are no rounding errors between what you type and what you send.
PdsAmountEntry is the full amount screen for a UPI payment: payee header, amount readout, validation, an optional note, keypad and confirm action. It handles the details Indian users notice:
- Lakh/crore grouping as you type. ₹1,23,456.50, not ₹123,456.50. Digits roll in one by one and commas slide to their new positions.
- Limits explained in words. The UPI maximum and the ₹1 minimum each come with a plain-language message and an icon. Rejected input shakes; crossing the limit gives a heavy haptic.
- Hardware keys. Type digits directly, and press Enter to pay.
- Screen readers. The amount is a live region read as currency (“Amount ₹450”), and the readout scales to fit at 200% text.
UPI allows up to ₹1,00,000 in one payment.
Try it
- Type ₹1,23,456.50. Grouping follows lakh and crore.
- Push past the limit, or add a third decimal.
- Hold ⌫ or press Esc to clear.
- Press Enter or the pay button.
Focus the phone to type with your keyboard.
Screen reader hearsAmount ₹1,23,456.50
PdsAmountEntryPdsKeypadLive demoThe keypad underneath, PdsKeypad, fires on press rather than release, with no delay even inside scroll views. Hold delete and a red fill sweeps across to clear the amount. An “Add a note” pill (PdsNoteField) morphs into an inline field and collapses back, keeping the screen calm.
The checkout journey, end to end
The Checkout flow pattern in the catalogue strings the components into one journey:
- Amount entry with the keypad and live validation.
- Confirm with a pay button or a slide.
- Processing, with hyperspace streaks streaming from the spinner.
- Result: success with a shockwave and aurora, or failure with a plain reason and Try again.
In the Widgetbook you can make the first attempt fail (the retry then succeeds), set the processing time, and choose “Confirm with: button / slide”.
01 Amount entry
02 Confirm
03 Processing
04 Result
A pay button that never freezes
PdsPayButton is a full-width pill with a lit gradient. When the payment starts, it contracts into a circle with a spinner, so the press visibly becomes progress instead of the button freezing. It presses to 0.96 scale with a spring release, uses tabular figures so the amount never jitters, and stays disabled until the amount is valid. Screen readers hear “Processing payment” while it loads.
Slide to pay: a tap never pays
Money movement is irreversible, so PdsSlideToPay asks for a deliberate gesture. A plain tap never pays; it teaches the gesture.
- The thumb follows your finger 1:1 with a trail.
- It commits past 82% of the track, or on a fast flick past 40%. Otherwise it springs back.
- A haptic tick plays every fifth of the track, and a heavy haptic on commit.
- Press and hold is the alternative: a ring fills, and the payment goes through. Screen readers get one button, “<label>, double tap to pay”, and Enter or Space works on a keyboard.
PdsSlideToPayLive demo · demo thresholdsThe impact beat
The success screen, PdsPaymentStatus, is the showpiece. Several GPU effects play in sequence, and they are all timed to one callback.
While the payment processes, hyperspace streaks stream from the spinner. The spinner is PdsStatusMark, one continuous object that goes from arc to closed ring to filled disc to a drawn check or cross. The moment the ring floods into a disc, it fires onImpact. On that beat the streaks jump to light speed and stop dead, a refractive shockwave bends the whole screen, and an aurora blooms. Then the content staggers in.
Payment successful
₹450
Paid to Priya Sharma · UPI ref 4821 0937 5512
PdsPaymentStatusPdsStatusMark.onImpactReplay · outcome · slow timeSeveral effects timed to one callback: that is the whole trick. PdsStatusMark exposes onImpact as the beat other effects should land on, so your own screens can use it too.
Failure gets its own treatment: a smaller red wave, a shake, a plain-language reason and a Try again button. In both cases the haptic lands when the check or cross finishes drawing, not when the state flips. Screen readers hear the result explicitly, such as “Payment successful. ₹450 paid to Priya Sharma.”, and failures are announced assertively.
(1) Spinner and warp streaks
processing
(2) Ring floods to a disc
onImpact fires
(3) Shockwave ring bending the screen
1100ms · 28px
(4) Aurora with content staggered in
result
Five hand-written shaders (Beta)
The effects are fragment shaders written in GLSL: small programs that run on the GPU for every pixel. Every one is purely decorative and never carries information on its own. Each switches off under reduced motion and never flashes more than three times a second.
| Widget | What it does | Key parameters |
|---|---|---|
| PdsWarpField | Hyperspace streaks radiating from a point. Speed and opacity are targets the field eases toward, so streaks never jump. At opacity 0 it stops ticking. | speed (0 drift, 1 cruise, >1 jump) · opacity · origin |
| PdsShockwave | A refractive lens ring through any widget subtree. The child is captured to a texture only while the wave travels, so at rest it renders and takes taps normally. | trigger · duration = 1100ms · strength = 28px |
| PdsAurora | A slow gradient bloom built from fractal noise, fading to nothing at every edge so it never shows a seam. Fades in over 1.2s. | visible · colors · opacity = 0.5 |
| PdsHoloFoil | Iridescent foil over any child: diffraction bands, a glare sliding opposite the tilt, sparkles. Used on payment cards. | tilt · intensity = 1 |
| PdsDissolve | Burns a child away along a glowing, noisy edge. Falls back to a plain fade when shaders aren’t available. | progress · origin · edgeColor = gold |
PdsWarpFieldBetaPdsShockwaveBetaPdsAuroraBetaPdsHoloFoilBetaPdsDissolveBeta
Cards, history and rewards
Cards that feel like cards
PdsPaymentCardView shows a tokenised card at ID-1 proportions, the standard bank-card size. It tilts in real 3D toward the pointer under a holographic foil shader, and the token number reveals in staggered groups of four. Screen readers hear one clean sentence, such as “Northwind Bank Visa Credit card ending 4242, default”, with decoration left out.
PdsCardWallet stacks several cards. Back cards peek above, each level scaled 3.5% smaller and dimmed 7%. Drag the front card to tilt it; fling it and it tucks behind the stack with your release velocity. A revealed token number sits behind a draining countdown ring and hides itself after 5 seconds, a security measure you can configure. Every swipe has a tap alternative.
Drag the front card to tilt it. Fling it to tuck it behind.
Screen reader hearsNorthwind Bank credit card ending 4242, default
PdsCardWalletPdsPaymentCardViewLive demoHistory you can scan
PdsTransactionList groups payments by day under sticky headers (“Today”, “Yesterday”, “Mon, 6 Oct”). It adds search across names and notes, filters for Sent, Received, Pending and Failed, skeleton loading and empty states that explain themselves, with a “Clear filters” action. Rows stagger in at 35ms steps.
Status is never colour alone. Credits show a “+” in green, failed rows are struck through and labelled, and each row reads as one sentence: “Paid ₹450 to Priya Sharma, success, 2:15 pm”. PdsStatusChip always pairs an icon with a label; only Pending breathes softly.
History
Today
Yesterday
Mon, 6 Oct
Each row reads as one sentence“Paid ₹450 to Priya Sharma, success, 2:15 pm”
Hover or focus a row. Filter, search or refresh.
PdsTransactionListPdsStatusChipLive demoPdsReceiptCard finishes the story: a ticket-style UPI receipt with notched edges and a perforation line. The 12-digit UPI reference is grouped in fours and copies to the clipboard with a check-mark confirmation.
Rewards without exclusion
PdsScratchCard is scratch-to-reveal. An idle sheen invites touch, scratching sheds flakes with haptic ticks, and past half the card the foil burns away from your last scratch point while confetti flutters. A visible “Reveal without scratching” button is always there. Cover colours are configurable, for example a red-and-yellow Diwali foil.
PdsScratchCardLive demo · foil presets: Gold, DiwaliAccessibility is tested, not asserted
Pay DS is designed and tested against WCAG 2.2 AA criteria. A test file, test/a11y_audit_test.dart, runs Flutter’s guideline checkers on every component in light and dark, renders each one at 200% text, and checks contrast for every semantic colour pair. It runs on every deploy.
Every gesture has another way
Gestures are fast for some people and impossible for others. So every gesture in Pay DS has a non-gesture path:
- Slide to pay → press and hold
- Scratch card → “Reveal without scratching” button
- Wallet swipe → tap
- Keypad hold-to-clear → Esc
What the tests cover
| WCAG 2.2 criterion | How Pay DS meets it |
|---|---|
| 1.3.1 Info & relationships | Headings, merged rows, mutually exclusive filter groups, selected states |
| 1.4.3 Contrast (text) | Every token text pair ≥ 4.5:1, verified in light and dark by test |
| 1.4.4 Resize text | Every component rendered at 200% text without overflow, by test |
| 1.4.11 Non-text contrast | Status disc, thumb, outlines ≥ 3:1, by test |
| 2.1.1 Keyboard | Filters, cards, slide, scratch, copy: Tab + Enter/Space; amount: direct typing |
| 2.2.1 Timing adjustable | Card-number auto-hide is configurable and repeatable |
| 2.3.1 Three flashes | No effect flashes; the shockwave is a single slow wave |
| 2.4.7 Focus visible | 2px primary focus ring on custom controls, keyboard only |
| 2.5.1 Pointer gestures | Slide → hold; scratch → reveal button; swipe → tap |
| 2.5.8 Target size | Android 48dp and iOS 44pt guidelines pass on every component, by test |
| 3.3.1 Error identification | Limit and minimum errors explained in words plus an icon |
| 4.1.2 Name, role, value | Labelled tap targets guideline passes on every component, by test |
| 4.1.3 Status messages | Payment result, reveal, copy and default change are announced |
Automated checks have limits, and we say so. The Widgetbook’s own guidance: automated checks catch roughly a third of real issues. Before shipping, test with TalkBack and VoiceOver on real devices, with Switch Access, and at the largest system font size.
Six ideas we’re exploring
Beyond the stable components, the catalogue holds six interactive prototypes. They are explorations, not shipped features, and none of them moves real money. Each one starts from a problem with today’s payment apps.
| # | Concept | The problem | The idea | What it would need |
|---|---|---|---|---|
| 01 | Sentence pay | A payment is split across four screens; you only see it whole on a confirm sheet you’re least likely to read. | One editable sentence, “Pay Priya ₹500 for dinner from Northwind Bank”, where each underlined word is a slot. Free text or voice is parsed, including “2k”. | — |
| 02 | Friction that fits | ₹40 at a tea stall and ₹40,000 to a stranger get the same confirm step, which is how scams work. | Effort scales with risk: tap for routine, slide for a friend, type two digits and hold for a stranger, a pause and plain warning when flagged. | A server-side risk score; thresholds owned by the risk team |
| 03 | Hand it over | ₹50 and ₹50,000 look identical on screen. | The amount becomes an object, from coin to bundle. Heavier money lags your finger and needs a harder flick. A button does the same for screen readers. | — |
| 04 | Money in motion | Instant payments are irreversible, and stuck ones just say “pending”. | A few seconds to “Catch it back”, then a live route from your bank through the UPI network to theirs, in plain words. | Holding the debit a few seconds; hop-level status from the PSP |
| 05 | Bill-first split | Apps only understand “divide by N”. | Scan a receipt, drag item chips onto people, spread tax by share, then request each exact share in one tap. | On-device OCR; multi-payee collect requests |
| 06 | Settle stack | Home screens are launchers; real to-dos hide in SMS and notifications. | Open to a short stack of what you owe: swipe right to pay, left to snooze. Amounts of ₹10,000+ still need hold-to-confirm. | — |
Two lines from the concept pages sum up the thinking. On Friction that fits: breaking the tap-tap-done rhythm is the single best defence against social-engineering scams. On Sentence pay: the confirmation is the input, so there is no separate review screen to skip.
Getting started
Pay DS ships as a Flutter package, pay_ds, installed from a local path or a private pub server. Five steps take you from install to a working payment.
1. Add the package
# pubspec.yaml
dependencies:
pay_ds:
path: ../pay_ds # or your private pub server2. Apply the theme
PdsTheme builds light and dark Material 3 themes in Plus Jakarta Sans, with payment status colours as a ThemeExtension. PdsShaders.precache() loads the effect shaders before first use, so the first animation doesn’t stutter.
import 'package:pay_ds/pay_ds.dart';
void main() {
PdsShaders.precache(); // warm the effect shaders
runApp(MaterialApp(
theme: PdsTheme.light(),
darkTheme: PdsTheme.dark(),
home: const PayScreen(),
));
}3. Compose a payment
upi.pay, payee and result stand in for your app’s own payment code.
PdsAmountEntry(
payeeName: payee.name,
payeeHandle: payee.upiId,
confirmStyle: PdsConfirmStyle.slide,
onSubmit: (paise) async {
final result = await upi.pay(payee, paise); // your payment call
if (!context.mounted) return;
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => PdsPaymentStatus(
phase: result.ok ? PaymentPhase.success : PaymentPhase.failed,
amountPaise: paise,
payeeName: payee.name,
referenceId: result.reference,
onDone: () => Navigator.of(context).pop(),
),
));
},
)4. Connect your APIs
Components are controlled: you own loading, paging and errors; they own presentation. Models parse your wire format with fromJson and degrade safely. An unknown status reads as pending, and missing card art falls back to the network colours. A PaymentsRepository interface covers cursor-paged history and token reveal behind device auth.
5. Localise
Override any subset of strings; the rest default to English.
PdsStringsScope(
strings: PdsStrings(
payAmount: (amount) => l10n.payAmount(amount),
paymentFailed: l10n.paymentFailed,
// ...any subset; the rest default to English
),
child: app,
)Using tokens directly
Padding(
padding: const EdgeInsets.all(PdsSpace.lg),
child: AnimatedContainer(
duration: PdsMotion.medium,
curve: PdsMotion.easeOut,
decoration: BoxDecoration(
color: context.pdsStatus.successContainer,
borderRadius: BorderRadius.circular(PdsRadius.card),
),
),
)The fastest way to understand Pay DS is to open the Widgetbook, switch on dark mode, push text to 200%, slow time down, and run the checkout flow.
Open the Widgetbook