sensor_shadows
Light that follows your hands. Tactile Flutter cards, buttons, and custom surfaces with shadows and highlights driven by your phone's accelerometer.
One shared sensor stream powers an entire screen. Smooth noisy readings, calibrate a comfortable resting pose, or supply manual tilt for previews. Reduced-motion preferences are respected by default.
Install
dependencies:
sensor_shadows: ^0.1.2
Requires Dart 3.5+ and Flutter 3.24+. Native builds also need the toolchain
required by sensors_plus 7.1.0: its Android plugin uses Java 17, Kotlin 2.2.0,
and Android Gradle Plugin 8.12.1. Use a current Flutter-generated Android
project or align an older host project's build tooling. The bundled example
was generated with Flutter 3.47.
iOS setup
Add this entry inside the root <dict> in ios/Runner/Info.plist:
<key>NSMotionUsageDescription</key>
<string>Motion data moves shadows and lighting as you tilt your phone.</string>
The sensor dependency requires this key. Its minimum iOS version is 12; your Flutter SDK may require a newer deployment target. The example includes this configuration.
Quick start
import 'package:flutter/material.dart';
import 'package:sensor_shadows/sensor_shadows.dart';
void main() {
runApp(SensorShadows(
child: MaterialApp(
home: Scaffold(
backgroundColor: const Color(0xFFE9E8E2),
body: Center(
child: SensorShadowCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Tilt your phone'),
const SizedBox(height: 24),
SensorShadowButton(
onPressed: () {},
child: const Text('Feel the light'),
),
],
),
),
),
),
),
));
}
SensorShadows sits once around MaterialApp. It owns one controller,
subscribes once, and reaches package surfaces in every route, dialog, and overlay.
Use SensorShadowScope inside part of an app only when you need a separate light
source or controller. A surface without either wrapper renders static lighting.
Flutter does not provide a global hook that can change shadow offsets painted by
arbitrary descendants. The app wrapper therefore powers every package-aware
SensorShadow, SensorShadowCard, and SensorShadowButton, but it cannot alter
shadows internal to stock Card, Material, or an unrelated Container. Replace
only widgets whose shadows should move with the matching package component; no
per-widget controller or scope is needed.
Customize a surface
const SensorShadow(
style: SensorShadowStyle(
color: Color(0xFF365E4D),
shadowColor: Color(0x50000000),
highlightColor: Color(0xFFFFFFFF),
maxOffset: 24,
blurRadius: 30,
spreadRadius: 0,
lightIntensity: 0.15,
borderRadius: BorderRadius.all(Radius.circular(20)),
ambientOffset: Offset(0, 4),
),
padding: EdgeInsets.all(24),
child: Text('A tactile surface', style: TextStyle(color: Colors.white)),
)
Use transparent content backgrounds to keep the surface lighting visible.
Shadows paint outside the widget's layout bounds: reserve surrounding space and
avoid ancestor clipping. Set clipBehavior to clip content to the surface shape.
Clipping content does not clip the shadow itself.
Control tilt and calibration
Create a controller in State.initState, pass it to a scope or directly to a
surface, and dispose it in State.dispose:
final controller = SensorShadowController(
smoothing: 0.15, // Fraction of each new reading applied: (0, 1].
sensitivity: 1.2,
samplingPeriod: const Duration(milliseconds: 20),
onError: (error, stackTrace) {
debugPrint('Motion unavailable: $error');
},
);
// In build:
SensorShadowScope(controller: controller, child: const MyContent());
// A user-triggered calibration action:
controller.calibrate();
// Manual preview (each axis is clamped to -1…1):
controller.stop();
controller.setTilt(const Offset(0.6, -0.4));
// Resume sensors, or clear calibration:
controller.start();
controller.reset();
// In State.dispose:
controller.dispose();
MyContent above represents your own widget. The runnable example shows the
complete lifecycle. Externally supplied controllers remain caller-owned: scopes
never stop or dispose them. For battery-sensitive reduced-motion handling with an
external controller, call stop() when MediaQuery.disableAnimations is true
(as the example does). Controllers always unsubscribe while the app is inactive,
paused, hidden, or detached, and reconnect on resume if sampling was enabled.
value is a listenable normalized Offset. The controller normalizes the
three-axis gravity vector, maps it to (-x, y), subtracts the calibrated pose,
applies sensitivity, clamps both axes, and smooths the result. This is a visual
effect, not an angle measurement: rapid linear acceleration can influence it.
Smaller smoothing values are calmer but lag more; smoothing is per sample.
Invalid and near-zero samples are ignored. A sensor error resets the output to
neutral and invokes onError; call start() to retry.
Orientation
The default mapping uses the device's native sensor axes. It does not infer
screen rotation. For a known rotated layout, supply quarterTurns (clockwise,
modulo four) when creating the controller. Recreate the controller when the
screen rotation changes, or remap samples in a custom stream. The example's
sensor instructions assume portrait orientation; manual mode works in any layout.
Custom sensor input
final samples = Stream<TiltSample>.periodic(
const Duration(milliseconds: 20),
(_) => const TiltSample(-3, 2, 9),
).asBroadcastStream();
final controller = SensorShadowController(samples: samples);
Samples include gravity, in m/s². Use a broadcast stream that supports re-listening after cancellation for background/resume and stop/start. The caller owns the input stream; the controller owns only its subscription. Custom streams also work on desktop and in deterministic tests.
Platforms and accessibility
| Platform | Behavior |
|---|---|
| Android | Accelerometer input when available; no runtime motion permission needed for the default rate. |
| iOS | Accelerometer input; configure NSMotionUsageDescription. Test on real hardware. |
| Web | Browser-dependent sensor support; permission policies and secure-context requirements may apply. Sampling interval may be ignored. Use manual input when unavailable. |
| macOS, Windows, Linux | Static neutral lighting by default; manual or custom stream input supported. |
SensorShadowScope and SensorShadow respect
MediaQuery.disableAnimations by default. Disabling a scope renders neutral
lighting and stops its internally owned sensor controller. Explicitly setting
respectReducedMotion: false opts a widget out of its own check; an enclosing
disabled scope still wins.
Buttons use Flutter's TextButton for touch, focus, keyboard activation, disabled
semantics, and press feedback, with a minimum 48×48 logical pixel target. Choose
foreground and surface colors with appropriate contrast. Cards and generic
surfaces preserve their children's semantics.
Run the full example
cd example
flutter pub get
flutter run # Connected Android or iOS device
flutter run -d chrome # Turn on “Manual tilt” to explore on desktop
The lighting studio includes cards, enabled and disabled buttons, color swatches, live tilt coordinates, manual X/Y controls, calibration, shadow travel, and a motion toggle. Physical sensor availability varies by device and browser; simulators may produce no readings.
API overview
| Type | Purpose |
|---|---|
SensorShadows |
Wrap MaterialApp once and share tilt across the whole app. |
SensorShadowScope |
Share a sensor controller across descendants. |
SensorShadowController |
Sampling, lifecycle, smoothing, calibration, and manual input. |
TiltSample |
Injectable gravity-inclusive acceleration reading. |
SensorShadowStyle |
Surface colors, shadow geometry, and highlight strength. |
SensorShadow |
General-purpose decorated surface. |
SensorShadowCard |
Surface with card padding and margins. |
SensorShadowButton |
Material button with dynamic lighting. |
Public APIs include Dart documentation comments. Generate HTML API docs with
dart doc; output is written to doc/api/.
Development and publishing
flutter pub get
dart format --output=none --set-exit-if-changed lib test example/lib example/test
flutter analyze
flutter test --coverage
cd example
flutter test
flutter build web --release
cd ..
dart doc
dart pub publish --dry-run
See PUBLISHING.md for the release checklist. This repository
contains no fabricated repository URLs; add your public repository and issue
tracker to pubspec.yaml before release if available. Publishing requires your
pub.flutter-io.cn account and ownership of the package name.
License
MIT.
Sensor integration uses sensors_plus,
maintained by Flutter Community and licensed under BSD-3-Clause.
Libraries
- sensor_shadows
- Accelerometer-driven shadows and lighting for Flutter surfaces.