Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active January 8, 2026 00:23
Show Gist options
  • Select an option

  • Save PlugFox/82ea9e73e60968bb068b4fb104dae262 to your computer and use it in GitHub Desktop.

Select an option

Save PlugFox/82ea9e73e60968bb068b4fb104dae262 to your computer and use it in GitHub Desktop.
Ribbon widget for Flutter
/*
* Ribbon widget for Flutter.
* https://gist.github.com/PlugFox/82ea9e73e60968bb068b4fb104dae262
* https://dartpad.dev?id=82ea9e73e60968bb068b4fb104dae262
* Mike Matiunin <plugfox@gmail.com>, 07 January 2026
*/
// ignore_for_file: curly_braces_in_flow_control_structures
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show RenderProxyBoxMixin, RenderObjectWithChildMixin, PipelineOwner;
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Ribbon',
debugShowCheckedModeBanner: false,
home: RibbonWidget(
label: 'Ribbon demo'.toUpperCase(),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[Colors.deepPurple, Colors.deepOrange],
),
child: Scaffold(
appBar: AppBar(title: const Text('Ribbon')),
body: const SafeArea(child: Center(child: Text('Hello World'))),
),
),
);
}
/// {@template ribbon_widget}
/// Ribbon widget that decorates its child with a ribbon on the top-right corner.
/// {@endtemplate}
class RibbonWidget extends SingleChildRenderObjectWidget {
/// {@macro ribbon_widget}
const RibbonWidget({
required Widget super.child,
this.label = 'MOST POPULAR',
this.textStyle = const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
height: 1,
),
this.gradient = const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[Color(0xFFFFB900), Color(0xFFF0B100)],
),
this.shadows = const <BoxShadow>[
BoxShadow(
color: Color(0x1A000000),
offset: Offset(0, 10),
blurRadius: 15,
spreadRadius: -3,
),
BoxShadow(
color: Color(0x1A000000),
offset: Offset(0, 4),
blurRadius: 6,
spreadRadius: -4,
),
],
super.key, // ignore: unused_element_parameter
});
/// Label text displayed on the ribbon.
final String label;
/// Text style for the label.
final TextStyle textStyle;
/// Gradient used for the ribbon background.
final Gradient gradient;
/// Shadows applied to the ribbon.
final List<BoxShadow> shadows;
@override
RenderObject createRenderObject(BuildContext context) =>
RibbonWidgetRenderObject(
label: label,
textStyle: textStyle,
gradient: gradient,
shadows: shadows,
);
@override
void updateRenderObject(
BuildContext context,
covariant RibbonWidgetRenderObject renderObject,
) {
if (identical(renderObject.label, label) &&
identical(renderObject.textStyle, textStyle) &&
identical(renderObject.gradient, gradient) &&
identical(renderObject.shadows, shadows))
return;
renderObject
..label = label
..textStyle = textStyle
..gradient = gradient
..shadows = shadows
..markNeedsPaint();
}
}
class RibbonWidgetRenderObject extends RenderBox
with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {
RibbonWidgetRenderObject({
required this.label,
required this.textStyle,
required this.gradient,
required this.shadows,
});
String label;
TextStyle textStyle;
Gradient gradient;
List<BoxShadow> shadows;
@override
// ignore: unnecessary_overrides
void attach(PipelineOwner owner) {
super.attach(owner);
PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange);
}
@override
@protected
void detach() {
PaintingBinding.instance.systemFonts.removeListener(
_handleSystemFontsChange,
);
super.detach();
}
@override
void performLayout() {
size =
(child?..layout(constraints, parentUsesSize: true))?.size ??
computeSizeForNoChild(constraints);
}
/// Handles system font changes by marking the render object as needing paint
/// to ensure text is redrawn with updated fonts.
/// This is important for non latin fonts that may have different metrics and
/// can be downloaded at runtime. Such as Korean, Chinese, Japanese, etc.
void _handleSystemFontsChange() {
markNeedsPaint();
}
@override
void paint(PaintingContext context, Offset offset) {
// Validate input
final child = this.child;
final size = this.size;
if (child == null || size.isEmpty) return;
// Draw child
context.paintChild(child, offset);
// Draw ribbon
final canvas = context.canvas;
// Create and layout text painter to measure text dimensions
final textPainter = TextPainter(
text: TextSpan(text: label, style: textStyle),
textDirection: TextDirection.ltr,
textAlign: TextAlign.center,
)..layout();
// Ribbon dimensions
// 35° in radians (positive for clockwise rotation)
const ribbonAngle = 35.0 * math.pi / 180.0;
const horizontalPadding = 24.0;
const verticalPadding = 6.0;
// Extra length to make ribbon longer (will be clipped)
const extraRibbonLength = 120.0;
// Text rect with padding
final textWidth = textPainter.width;
final textHeight = textPainter.height;
final ribbonWidth = textWidth + horizontalPadding * 2 + extraRibbonLength;
final ribbonHeight = textHeight + verticalPadding * 2;
// Calculate bounding box of rotated text rectangle
// When rotated by angle θ, rectangle WxH has bounding box:
// boundingWidth = W * cos(θ) + H * sin(θ)
// boundingHeight = W * sin(θ) + H * cos(θ)
final cos35 = math.cos(ribbonAngle), sin35 = math.sin(ribbonAngle);
final boundingWidth = textWidth * cos35 + textHeight * sin35;
final boundingHeight = textWidth * sin35 + textHeight * cos35;
// Add padding to bounding box
final totalBoundingWidth = boundingWidth + horizontalPadding * 2;
final totalBoundingHeight = boundingHeight + verticalPadding * 2;
// Start a new layer and clip to bounds
// to prevent drawing outside the widget
canvas
..save()
..clipRect(offset & size);
// Position: top-right corner minus the bounding width (so it fits inside)
// Plus a small offset to let part of it extend beyond
final cornerOffset = totalBoundingWidth * 0.15; // 15% extends beyond corner
canvas
..translate(
offset.dx + size.width - totalBoundingWidth + cornerOffset,
offset.dy,
)
// Move to center of where the ribbon will be
..translate(totalBoundingWidth / 2, totalBoundingHeight / 2)
// Rotate the canvas
..rotate(ribbonAngle);
// Draw ribbon background (centered at origin after rotation)
final ribbonRect = Rect.fromLTWH(
-ribbonWidth / 2,
-ribbonHeight / 2,
ribbonWidth,
ribbonHeight,
);
// Draw shadows
for (final shadow in shadows) {
final shadowPaint = shadow.toPaint();
canvas.drawRect(ribbonRect.shift(shadow.offset), shadowPaint);
}
// Draw ribbon with gradient
final ribbonPaint = Paint()
..shader = gradient.createShader(ribbonRect)
..style = PaintingStyle.fill;
// Draw ribbon rectangle
canvas.drawRect(ribbonRect, ribbonPaint);
// Draw text centered on the ribbon
textPainter.paint(canvas, Offset(-textWidth / 2, -textHeight / 2));
// Restore canvas and apply clipping
canvas.restore();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment