border_beam_effect 1.0.0 copy "border_beam_effect: ^1.0.0" to clipboard
border_beam_effect: ^1.0.0 copied to clipboard

A configurable shape-agnostic animated border beam and inner glow for Flutter.

example/lib/main.dart

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;
}
6
likes
160
points
90
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A configurable shape-agnostic animated border beam and inner glow for Flutter.

Repository (GitHub)
View/report issues

Topics

#animation #border #glow #ui #effects

License

MIT (license)

Dependencies

flutter

More

Packages that depend on border_beam_effect