border_beam_effect 1.0.0
border_beam_effect: ^1.0.0 copied to clipboard
A configurable shape-agnostic animated border beam and inner glow for Flutter.
import 'dart:math' as math;
import 'package:border_beam_effect/border_beam_effect.dart';
import 'package:flutter/material.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Border Beam Effect',
theme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 254, 255, 255),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF090B10),
),
home: const ExampleGallery(),
);
}
}
class ExampleGallery extends StatefulWidget {
const ExampleGallery({super.key});
@override
State<ExampleGallery> createState() => _ExampleGalleryState();
}
class _ExampleGalleryState extends State<ExampleGallery> {
bool enabled = true;
@override
Widget build(BuildContext context) {
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment.topLeft,
radius: 1.4,
colors: [
Color.fromARGB(255, 16, 16, 16),
Color.fromARGB(255, 6, 6, 6),
],
),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_GalleryHeader(
enabled: enabled,
onEnabledChanged: (value) =>
setState(() => enabled = value),
),
const SizedBox(height: 32),
Wrap(
spacing: 24,
runSpacing: 24,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
ShowcasePanel(
title: 'Superellipse prompt',
subtitle: 'Layered adaptive glow',
width: 620,
child: TunableBeamExample(
initialBlurRadius: 18,
initialBeamExtent: .42,
builder: (blurRadius, beamExtent) => PromptExample(
enabled: enabled,
blurRadius: blurRadius,
beamExtent: beamExtent,
),
),
),
ShowcasePanel(
title: 'Compact actions',
subtitle: 'Pill and circular masks',
width: 420,
child: TunableBeamExample(
initialBlurRadius: 6,
initialBeamExtent: .34,
builder: (blurRadius, beamExtent) => ActionExample(
enabled: enabled,
blurRadius: blurRadius,
beamExtent: beamExtent,
),
),
),
ShowcasePanel(
title: 'Arbitrary path',
subtitle: 'The same effect on a custom shape',
width: 420,
child: TunableBeamExample(
initialBlurRadius: 14,
initialBeamExtent: .28,
builder: (blurRadius, beamExtent) => StarExample(
enabled: enabled,
blurRadius: blurRadius,
beamExtent: beamExtent,
),
),
),
ShowcasePanel(
title: 'Shape morph',
subtitle: 'A squircle smoothly becomes a circle',
width: 420,
child: TunableBeamExample(
initialBlurRadius: 16,
initialBeamExtent: .34,
builder: (blurRadius, beamExtent) =>
MorphingShapeExample(
enabled: enabled,
blurRadius: blurRadius,
beamExtent: beamExtent,
),
),
),
ShowcasePanel(
title: 'Responsive compact action',
subtitle: 'Tap to expand or collapse',
width: 420,
child: TunableBeamExample(
initialBlurRadius: 7,
initialBeamExtent: .34,
builder: (blurRadius, beamExtent) =>
ResizableActionExample(
enabled: enabled,
blurRadius: blurRadius,
beamExtent: beamExtent,
),
),
),
],
),
],
),
),
),
),
),
),
);
}
}
class _GalleryHeader extends StatelessWidget {
const _GalleryHeader({required this.enabled, required this.onEnabledChanged});
final bool enabled;
final ValueChanged<bool> onEnabledChanged;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 24,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const SizedBox(
width: 520,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Border Beam Effect',
style: TextStyle(fontSize: 34, fontWeight: FontWeight.w700),
),
SizedBox(height: 8),
Text(
'Animated light for any Flutter path — no fragment shaders.',
style: TextStyle(fontSize: 16, color: Colors.white54),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Effect'),
const SizedBox(width: 8),
Switch(value: enabled, onChanged: onEnabledChanged),
],
),
],
);
}
}
class ShowcasePanel extends StatelessWidget {
const ShowcasePanel({
super.key,
required this.title,
required this.subtitle,
required this.width,
required this.child,
});
final String title;
final String subtitle;
final double width;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: width,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .035),
border: Border.all(color: Colors.white10),
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(subtitle, style: const TextStyle(color: Colors.white38)),
const SizedBox(height: 28),
Center(child: child),
],
),
);
}
}
class TunableBeamExample extends StatefulWidget {
const TunableBeamExample({
super.key,
required this.initialBlurRadius,
required this.initialBeamExtent,
required this.builder,
});
final double initialBlurRadius;
final double initialBeamExtent;
final Widget Function(double blurRadius, double beamExtent) builder;
@override
State<TunableBeamExample> createState() => _TunableBeamExampleState();
}
class _TunableBeamExampleState extends State<TunableBeamExample> {
late double blurRadius = widget.initialBlurRadius;
late double beamExtent = widget.initialBeamExtent;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
widget.builder(blurRadius, beamExtent),
const SizedBox(height: 24),
_ParameterSlider(
label: 'Blur',
value: blurRadius,
min: 1,
max: 200,
divisions: 199,
valueLabel: blurRadius.toStringAsFixed(0),
onChanged: (value) => setState(() => blurRadius = value),
),
_ParameterSlider(
label: 'Length',
value: beamExtent,
min: .05,
max: 1,
divisions: 95,
valueLabel: '${(beamExtent * 100).round()}%',
onChanged: (value) => setState(() => beamExtent = value),
),
],
),
);
}
}
class _ParameterSlider extends StatelessWidget {
const _ParameterSlider({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.valueLabel,
required this.onChanged,
});
final String label;
final double value;
final double min;
final double max;
final int divisions;
final String valueLabel;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 52,
child: Text(label, style: const TextStyle(color: Colors.white54)),
),
Expanded(
child: Slider(
value: value,
min: min,
max: max,
divisions: divisions,
label: valueLabel,
onChanged: onChanged,
),
),
SizedBox(
width: 42,
child: Text(
valueLabel,
textAlign: TextAlign.end,
style: const TextStyle(
color: Colors.white70,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
],
);
}
}
class PromptExample extends StatelessWidget {
const PromptExample({
super.key,
required this.enabled,
required this.blurRadius,
required this.beamExtent,
});
final bool enabled;
final double blurRadius;
final double beamExtent;
@override
Widget build(BuildContext context) {
final shape = RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(30),
);
return BorderBeamEffect(
mask: ShapeBorderClipper(shape: shape),
enableEffect: enabled,
borderColor: Colors.white,
borderWidth: 1,
beamExtent: beamExtent,
beamColors: const [
Color(0xFF58F5FF),
Color(0xFFFF5AD9),
Color(0xFF8CFF68),
],
glowLayers: _exampleGlowLayers(blurRadius),
child: Container(
width: 520,
padding: const EdgeInsets.all(22),
decoration: ShapeDecoration(
color: const Color(0xFF050607),
shape: shape,
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ask anything…',
style: TextStyle(fontSize: 20, color: Colors.white38),
),
SizedBox(height: 34),
Row(
children: [
_ModelChip(),
Spacer(),
Icon(Icons.add_circle_outline),
SizedBox(width: 18),
Icon(Icons.arrow_forward_rounded),
],
),
],
),
),
);
}
}
class _ModelChip extends StatelessWidget {
const _ModelChip();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(99),
),
child: const Text('Model name'),
);
}
}
class ActionExample extends StatelessWidget {
const ActionExample({
super.key,
required this.enabled,
required this.blurRadius,
required this.beamExtent,
});
final bool enabled;
final double blurRadius;
final double beamExtent;
@override
Widget build(BuildContext context) {
const pill = StadiumBorder();
const circle = CircleBorder();
const colors = [Color(0xFF51E9FF), Color(0xFFFF62D4)];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
BorderBeamEffect(
mask: const ShapeBorderClipper(shape: pill),
enableEffect: enabled,
borderColor: Colors.white,
beamColors: colors,
beamExtent: beamExtent,
glowLayers: _exampleGlowLayers(blurRadius),
child: Material(
color: const Color(0xFF080A0E),
shape: pill,
child: InkWell(
customBorder: pill,
onTap: () {},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 13),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome, size: 18),
SizedBox(width: 9),
Text('Generate'),
],
),
),
),
),
),
const SizedBox(width: 20),
BorderBeamEffect(
mask: const ShapeBorderClipper(shape: circle),
enableEffect: enabled,
borderColor: Colors.white,
beamColors: colors,
beamExtent: beamExtent,
gradientMode: BorderBeamGradientMode.rotating,
glowLayers: _exampleGlowLayers(blurRadius),
child: Material(
color: const Color(0xFF080A0E),
shape: circle,
child: IconButton(
onPressed: () {},
icon: const Icon(Icons.arrow_upward_rounded),
),
),
),
],
);
}
}
class StarExample extends StatelessWidget {
const StarExample({
super.key,
required this.enabled,
required this.blurRadius,
required this.beamExtent,
});
final bool enabled;
final double blurRadius;
final double beamExtent;
@override
Widget build(BuildContext context) {
return BorderBeamEffect(
mask: const StarClipper(points: 5, innerRadiusRatio: .46),
enableEffect: enabled,
gradientMode: BorderBeamGradientMode.rotating,
duration: const Duration(seconds: 4),
borderColor: const Color(0xFFFFF4C2),
beamColors: const [
Color(0xFFFFD84D),
Color(0xFFFF6B9D),
Color(0xFF7C8CFF),
],
beamExtent: beamExtent,
glowLayers: _exampleGlowLayers(blurRadius),
child: const SizedBox.square(
dimension: 160,
child: ColoredBox(color: Color(0xFF090A10)),
),
);
}
}
class MorphingShapeExample extends StatefulWidget {
const MorphingShapeExample({
super.key,
required this.enabled,
required this.blurRadius,
required this.beamExtent,
});
final bool enabled;
final double blurRadius;
final double beamExtent;
@override
State<MorphingShapeExample> createState() => _MorphingShapeExampleState();
}
class _MorphingShapeExampleState extends State<MorphingShapeExample>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2200),
)..repeat(reverse: true);
late final Animation<double> _progress = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _progress,
builder: (context, child) {
final clipper = MorphingSuperellipseClipper(_progress.value);
return BorderBeamEffect(
mask: clipper,
enableEffect: widget.enabled,
borderColor: Colors.white,
borderWidth: 1,
beamExtent: widget.beamExtent,
duration: const Duration(milliseconds: 2800),
gradientMode: BorderBeamGradientMode.rotating,
beamColors: const [
Color(0xFF69F0FF),
Color(0xFFA980FF),
Color(0xFFFF6EC7),
],
glowLayers: _exampleGlowLayers(widget.blurRadius),
child: child!,
);
},
child: const SizedBox.square(
dimension: 160,
child: ColoredBox(
color: Color(0xFF080A10),
child: Center(child: Icon(Icons.motion_photos_on_outlined, size: 34)),
),
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
class ResizableActionExample extends StatefulWidget {
const ResizableActionExample({
super.key,
required this.enabled,
required this.blurRadius,
required this.beamExtent,
});
final bool enabled;
final double blurRadius;
final double beamExtent;
@override
State<ResizableActionExample> createState() => _ResizableActionExampleState();
}
class _ResizableActionExampleState extends State<ResizableActionExample> {
bool expanded = true;
@override
Widget build(BuildContext context) {
const shape = StadiumBorder();
return TweenAnimationBuilder<double>(
tween: Tween(end: expanded ? 1 : 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeInOutCubic,
builder: (context, progress, _) {
final width = 54 + 156 * progress;
final detailsOpacity = Curves.easeOut.transform(
((progress - .3) / .7).clamp(0.0, 1.0),
);
return BorderBeamEffect(
mask: const ShapeBorderClipper(shape: shape),
enableEffect: widget.enabled,
borderColor: Colors.white,
borderWidth: 1,
beamExtent: widget.beamExtent,
glowLayers: _exampleGlowLayers(widget.blurRadius),
beamColors: const [Color(0xFF63EEFF), Color(0xFFFF65C8)],
child: GestureDetector(
onTap: () => setState(() => expanded = !expanded),
child: Container(
width: width,
height: 54,
decoration: const ShapeDecoration(
color: Color(0xFF080A0E),
shape: shape,
),
child: Stack(
alignment: Alignment.centerLeft,
children: [
const Positioned(
left: 17,
child: Icon(Icons.auto_awesome, size: 20),
),
Positioned(
left: 54,
child: Opacity(
opacity: detailsOpacity,
child: const Text(
'Generate',
maxLines: 1,
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
Positioned(
right: 16,
child: Opacity(
opacity: detailsOpacity,
child: const Icon(Icons.chevron_right_rounded, size: 20),
),
),
],
),
),
),
);
},
);
}
}
/// Interpolates the superellipse exponent from a squircle to a circle.
///
/// Rebuilding the clipper is enough: [BorderBeamEffect] follows the new path
/// on every animation frame.
class MorphingSuperellipseClipper extends CustomClipper<Path> {
const MorphingSuperellipseClipper(this.progress);
final double progress;
@override
Path getClip(Size size) {
final center = size.center(Offset.zero);
final radiusX = size.width / 2;
final radiusY = size.height / 2;
final exponent = 5 - 3 * progress;
final path = Path();
for (var index = 0; index <= 128; index++) {
final angle = index / 128 * math.pi * 2;
final cosine = math.cos(angle);
final sine = math.sin(angle);
final x = _signedPower(cosine, 2 / exponent) * radiusX;
final y = _signedPower(sine, 2 / exponent) * radiusY;
final point = center + Offset(x, y);
if (index == 0) {
path.moveTo(point.dx, point.dy);
} else {
path.lineTo(point.dx, point.dy);
}
}
return path..close();
}
double _signedPower(double value, double exponent) =>
value.sign * math.pow(value.abs(), exponent).toDouble();
@override
bool shouldReclip(covariant MorphingSuperellipseClipper oldClipper) =>
progress != oldClipper.progress;
}
List<BorderBeamGlow> _exampleGlowLayers(double blurRadius) {
final coreBlur = math.min(4.0, blurRadius);
final middleBlur = math.min(18.0, blurRadius);
return [
BorderBeamGlow(
blurRadius: coreBlur,
spread: math.min(1.5, blurRadius * .1),
opacity: 1,
blendMode: BlendMode.plus,
),
BorderBeamGlow(
blurRadius: middleBlur,
spread: math.min(6, blurRadius * .18),
opacity: .9,
blendMode: BlendMode.plus,
),
BorderBeamGlow(
blurRadius: blurRadius,
spread: math.min(48, blurRadius * .28),
opacity: .78,
blendMode: BlendMode.plus,
),
];
}
class StarClipper extends CustomClipper<Path> {
const StarClipper({required this.points, required this.innerRadiusRatio})
: assert(points >= 3),
assert(innerRadiusRatio > 0 && innerRadiusRatio < 1);
final int points;
final double innerRadiusRatio;
@override
Path getClip(Size size) {
final center = size.center(Offset.zero);
final outerRadius = size.shortestSide / 2;
final path = Path();
for (var index = 0; index < points * 2; index++) {
final radius = index.isEven
? outerRadius
: outerRadius * innerRadiusRatio;
final angle = -math.pi / 2 + index * math.pi / points;
final point = center + Offset(math.cos(angle), math.sin(angle)) * radius;
if (index == 0) {
path.moveTo(point.dx, point.dy);
} else {
path.lineTo(point.dx, point.dy);
}
}
return path..close();
}
@override
bool shouldReclip(covariant StarClipper oldClipper) =>
points != oldClipper.points ||
innerRadiusRatio != oldClipper.innerRadiusRatio;
}