はじめに
開発したプロダクトでのUI設計についてネオ・ブルータリズムを実装したので記事を書いていこうと思います。
目次
1. ネオ・ブルータリズムとは
一言で言うと
太い枠線 + ぼかしゼロの影 + ビビッドな色 = ネオ・ブルータリズム
ネオ・ブルータリズム(Neo-Brutalism)は、「素材感をそのまま見せる」 というブルータリズム建築の思想をデジタルUIに再解釈したデザインパターンです。
特徴的な印象
- 力強さ:太い線が「存在感」を生む
- 明快さ:要素の境界が曖昧にならない
- 遊び心:派手な色使いと大胆なレイアウト
なぜゲームアプリに採用したのか
このプロダクトはシミュレーションゲームです。UIでは、ゲームとしての ワクワク感やインパクト が不足していました。ネオ・ブルータリズムの力強さは、ゲームUIが求める「触りたくなる感覚」だと感じて採用しました。
他のデザインパターンとの比較
| 項目 | Material Design 3 | Glassmorphism | Neumorphism | Neo-Brutalism |
|---|---|---|---|---|
| 思想 | 物理的な「紙」 | 透明な「ガラス」 | 柔らかい「凹凸」 | デジタルの「ブロック」 |
| 影 | elevation + blur | なし or 軽い | 2方向(凹凸) | offset + blur:0 |
| 枠線 | なし or 極薄 | なし | なし | 2.5-3px の太線 |
| 角丸 | 12-28px | 大きめ | 大きめ | 2-4px |
| GPU負荷 | 中 | 高い(BackdropFilter) | 中 | 低い |
| アクセシビリティ | 高い | やや低い | 低い | 高い |
2. ビジュアル原則について
ネオ・ブルータリズムのUIは、以下の 5つの原則 で構成されます。
原則一覧
| 原則 | Material Design | Politownでの値 | |
|---|---|---|---|
| 1 | ソリッドシャドウ |
elevation + blur |
blurRadius: 0, Offset(4, 4)
|
| 2 | 太いボーダー | なし or 1px | borderWidth: 3.0 |
| 3 | 鮮やかな配色 | テーマに沿った穏やかな色 |
#FFB300(ゴールド) |
| 4 | 控えめな角丸 | 12-16px | borderRadius: 4.0 |
| 5 | 太いタイポグラフィ | Regular/Medium | FontWeight.w900 |
原則1: ソリッドシャドウ — blurRadius: 0 が全ての核心
通常の box-shadow は blurRadius を使ってぼかします。ネオ・ブルータリズムでは blurRadius を 0 にして、くっきりとした塗りつぶしの影を作ります。
BoxShadow(
color: Colors.black, // 枠と同じ色
offset: Offset(4, 4), // 右下に4pxずらす
blurRadius: 0, // ★ これがネオ・ブルータリズムの核心
)
影のサイズは要素の重要度に応じて使い分けます。
| 用途 | offset | 印象 |
|---|---|---|
| 小さいバッジ | Offset(2, 2) |
控えめ |
| アイコンボタン | Offset(3, 3) |
標準 |
| カード・ボタン | Offset(4, 4) |
力強い |
| ヒーロー要素 | Offset(6, 6) |
大胆 |
原則2: 太いボーダー
Border.all(
width: 3.0, // 通常の3倍
color: cs.onSurface,
)
鉄則:枠線と影は同じ色にする。 別の色にすると「ソリッドなブロック感」が崩れます。
final effectiveBorder = borderColor ?? cs.onSurface;
decoration: BoxDecoration(
border: Border.all(width: 3, color: effectiveBorder),
boxShadow: [
BoxShadow(color: effectiveBorder, offset: ..., blurRadius: 0),
// ↑ 枠線と同じ色 → 一体感のあるブロック表現
],
)
原則3: 鮮やかな配色
ネオ・ブルータリズムでは パステル寄りのビビッドカラー を多用します。Politownでは用途ごとに意味を持たせています。
class AppColors {
static const Color primary = Color(0xFFFFB300); // ゴールド — CTA
static const Color secondary = Color(0xFF2563EB); // ブルー — サブ要素
static const Color publicOpinion = Color(0xFF2563EB); // 世論
static const Color economy = Color(0xFFF59E0B); // 経済
static const Color welfare = Color(0xFF10B981); // 福祉
static const Color education = Color(0xFF8B5CF6); // 教育
}
原則4: 控えめな角丸
borderRadius: BorderRadius.circular(4), // たった4px
Material Design の borderRadius: 12 と比べて極めて小さいです。理由は3つあります。
- 直線的な力強さを保つ:角丸が大きいと「優しい」印象になる
- ブロック感:建築ブルータリズムのコンクリートブロックを想起させる
- 完全な0ではない:4px の角丸は「デジタルであること」の最低限の許容
原則5: 太いタイポグラフィ
ネオ・ブルータリズムのテキストは Bold 以上 が基本です。
// タイトル: w900 (Black) — 最大のインパクト
fontWeight: FontWeight.w900
// ボタンラベル: bold — 十分目立つが主張しすぎない
fontWeight: FontWeight.bold
// ステータスバー数値: w900 — ゲージとの対比で読みやすい
fontWeight: FontWeight.w900
3. インタラクション設計
影の位置に沈み込むボタン
ネオ・ブルータリズムの 最も重要なインタラクション は、ボタンが「影の位置に沈み込む」アニメーションです。Material Design の InkWell(波紋エフェクト)とは根本的に異なります。
アニメーションの3状態
状態1: 通常(t = 0.0)
┌─────────┐
│ Button │█ ← shadow offset: (4, 4)
└─────────┘█ ボタン移動: (0, 0)
██████████
状態2: 半押し(t = 0.5)
┌─────────┐
│ Button │█ ← shadow offset: (2, 2)
└─────────┘█ ボタン移動: (2, 2)
█████████
状態3: 完全押し込み(t = 1.0)
┌─────────┐
│ Button │ ← shadow offset: (0, 0) = 影消滅
└─────────┘ ボタン移動: (4, 4)
数学的な記述
t = アニメーション値 (0.0 → 1.0)
shadow = 初期オフセット (例: Offset(4, 4))
ボタンの移動量 = shadow * t
残りの影 = shadow * (1 - t)
合計位置は常に一定:
shadow * t + shadow * (1 - t) = shadow
ボタンの移動量と残りの影の合計が常に一定 であることが、「影の位置に沈む」自然な感覚を生みます。
Flutter での実装
// 1. 80msの超高速アニメーション
final ctrl = useAnimationController(
duration: const Duration(milliseconds: 80),
);
// 2. easeOutで自然な減速
final press = useMemoized(
() => CurvedAnimation(parent: ctrl, curve: Curves.easeOut),
);
// 3. GestureDetectorでタッチイベントを捕捉
GestureDetector(
onTapDown: (_) => ctrl.forward(), // 指が触れた → 沈む
onTapUp: (_) => handleTap(), // 指が離れた → 戻る + 実行
onTapCancel: () => ctrl.reverse(), // キャンセル → 戻るだけ
child: ...
)
// 4. AnimatedBuilderで毎フレーム再描画
AnimatedBuilder(
animation: press,
builder: (context, child) {
final t = press.value;
return Transform.translate(
offset: Offset(shadow.dx * t, shadow.dy * t), // ボタンが影方向へ移動
child: NeoBrutalContainer(
shadowOffset: Offset(
shadow.dx * (1 - t), // 影が縮む
shadow.dy * (1 - t),
),
child: child,
),
);
},
)
なぜ 80ms なのか
| 時間 | 印象 |
|---|---|
| 50ms | 速すぎて「沈む」感覚がない |
| 80ms | 瞬間的だが「沈んだ」と認知できる |
| 150ms | やや遅い、UIの反応性が落ちる |
| 300ms | Material標準だがネオブルには遅すぎる |
80ms + Curves.easeOut の組み合わせが、ネオ・ブルータリズムの「パキッ」とした操作感を生みます。
非同期安全性 — 実プロダクトで必須の3つのガード
Future<void> handleTap() async {
ctrl.reverse(); // まず見た目を戻す
try {
isLoading.value = true; // ① 連打防止ロック
await onPressed?.call(); // 非同期処理を実行
} finally {
if (context.mounted) { // ② Widgetが生きているか確認
isLoading.value = false; // ③ ロック解除(finallyで確実に)
}
}
}
-
isLoadingで連打防止: API呼び出し中に2回タップされるのを防ぐ -
context.mountedチェック: ナビゲーションで Widget が消えた後に setState しない -
try-finally: エラーが発生してもロックが必ず解除される
4. 実際の実装
ここからは Politown で実装した5つのコンポーネントを、コード付きで解説します。
ディレクトリ構成
lib/core/widgets/neo_brutal/
├── neo_brutal_container.dart # 基盤コンテナ(61行)
├── neo_brutal_button.dart # テキストボタン(116行)
├── neo_brutal_icon_button.dart # アイコンボタン(104行)
├── neo_brutal_map_tile.dart # グリッドタイル(120行)
└── neo_brutal_status_bar.dart # ゲージバー(90行)
全て100行前後と非常に小さいのが特徴です。
コンポーネント体系の設計判断
| コンポーネント | ベースクラス | Container使用 | 判断理由 |
|---|---|---|---|
| Container | StatelessWidget | - | 純粋な表示のみ |
| Button | HookWidget | 内部で使用 | hooks で宣言的に状態管理 |
| IconButton | HookWidget | 内部で使用 | Button と同じ設計方針 |
| MapTile | StatefulWidget | 不使用 | 大量生成のため hooks オーバーヘッドを回避 |
| StatusBar | StatelessWidget | 不使用 | Row内の3カラムレイアウトが Container に収まらない |
4-1. NeoBrutalContainer — 全ての基盤(61行)
全てのネオ・ブルータリズムウィジェットの土台となる 純粋な表示用コンテナ です。
class NeoBrutalContainer extends StatelessWidget {
const NeoBrutalContainer({
super.key,
this.child,
this.color,
this.borderColor,
this.shadowOffset = const Offset(4, 4),
this.borderWidth = 3.0,
this.borderRadius = 4.0,
this.padding,
this.width,
this.height,
});
final Widget? child;
final Color? color;
final Color? borderColor;
final Offset shadowOffset;
final double borderWidth;
final double borderRadius;
final EdgeInsetsGeometry? padding;
final double? width;
final double? height;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final effectiveBorder = borderColor ?? cs.onSurface;
return Container(
width: width,
height: height,
padding: padding,
decoration: BoxDecoration(
color: color ?? cs.surface,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(width: borderWidth, color: effectiveBorder),
boxShadow: shadowOffset != Offset.zero
? [
BoxShadow(
color: effectiveBorder,
offset: shadowOffset,
blurRadius: 0, // ★ ネオ・ブルータリズムの核心
),
]
: null,
),
child: child,
);
}
}
設計ポイント
-
shadowOffset != Offset.zeroでの条件分岐: 押し込みアニメーションの完了時にboxShadow: nullとすることで不要な描画を省く -
colorSchemeへのフォールバック:color ?? cs.surface,borderColor ?? cs.onSurfaceによりテーマ変更に自動追従 - StatelessWidget: 表示専用なので状態を持たない
使用例
// 基本的な使い方
NeoBrutalContainer(
padding: const EdgeInsets.all(16),
child: Text('Hello Neo-Brutalism!'),
)
// ネストしてタグを表現
NeoBrutalContainer(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text('カードタイトル', style: TextStyle(fontWeight: FontWeight.w900)),
NeoBrutalContainer(
color: cs.primaryContainer,
borderColor: cs.primary,
shadowOffset: const Offset(2, 2), // 親より小さい影
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text('タグ'),
),
],
),
)
4-2. NeoBrutalButton — へこむボタン(116行)
タップ時に影の位置に「へこむ」アニメーションを持つボタンです。
class NeoBrutalButton extends HookWidget {
const NeoBrutalButton({
super.key,
required this.label,
required this.onPressed,
this.sublabel,
this.color,
this.borderColor,
this.labelStyle,
this.shadowOffset = const Offset(4, 4),
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
this.enabled = true,
});
final String label;
final String? sublabel;
final Future<void> Function()? onPressed;
final Color? color;
final Color? borderColor;
final TextStyle? labelStyle;
final Offset shadowOffset;
final EdgeInsetsGeometry padding;
final bool enabled;
@override
Widget build(BuildContext context) {
// 80msのアニメーションコントローラー
final ctrl = useAnimationController(
duration: const Duration(milliseconds: 80),
);
final press = useMemoized(
() => CurvedAnimation(parent: ctrl, curve: Curves.easeOut),
);
final isLoading = useState(false);
final isInteractive = enabled && onPressed != null && !isLoading.value;
final cs = Theme.of(context).colorScheme;
final shadow = shadowOffset;
Future<void> handleTap() async {
ctrl.reverse();
try {
isLoading.value = true;
await onPressed?.call();
} finally {
if (context.mounted) isLoading.value = false;
}
}
return AnimatedBuilder(
animation: press,
builder: (context, child) {
final t = press.value;
return Transform.translate(
// ボタンが影の方向に移動
offset: Offset(shadow.dx * t, shadow.dy * t),
child: GestureDetector(
onTapDown: isInteractive ? (_) => ctrl.forward() : null,
onTapUp: isInteractive ? (_) => handleTap() : null,
onTapCancel: isInteractive ? () => ctrl.reverse() : null,
child: NeoBrutalContainer(
color: isInteractive
? (color ?? cs.primary)
: cs.surfaceContainerHighest,
borderColor: borderColor ?? cs.onSurface,
// 影が縮む(移動量 + 残り影 = 常に一定)
shadowOffset: Offset(
shadow.dx * (1 - t),
shadow.dy * (1 - t),
),
padding: padding,
child: child,
),
),
);
},
child: Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
);
}
}
ポイント
-
HookWidgetベースでuseAnimationControllerを使い宣言的に記述 -
非活性時 は
cs.surfaceContainerHighest(グレー系)に自動変化 -
isLoadingで連打防止 +context.mountedでアンマウント後の安全性確保
4-3. NeoBrutalIconButton — アイコン専用ボタン(104行)
NeoBrutalButton と同じ押し込みアニメーションを持ちますが、アイコン表示 に特化しています。
class NeoBrutalIconButton extends HookWidget {
const NeoBrutalIconButton({
super.key,
required this.icon,
required this.onPressed,
this.color,
this.borderColor,
this.shadowOffset = const Offset(3, 3), // Buttonより控えめ
this.size = 48,
this.iconSize = 20,
});
final IconData icon;
final Future<void> Function()? onPressed;
// ... 省略(アニメーション部分はButtonと同じ)
// ローディング中はアイコンがスピナーに差し替わる
child: isLoading.value
? Center(child: SizedBox(
width: iconSize - 2,
height: iconSize - 2,
child: CircularProgressIndicator(strokeWidth: 2),
))
: Center(child: Icon(icon, size: iconSize)),
}
Button との違い
| 項目 | NeoBrutalButton | NeoBrutalIconButton |
|---|---|---|
| 内容 | テキスト | IconData |
| サイズ | 可変(padding制御) | 固定(48px) |
| ローディング | なし |
CircularProgressIndicator に差し替え |
| shadowOffset | (4, 4) | (3, 3) — 小さい要素なので控えめ |
4-4. NeoBrutalMapTile — グリッドタイル(120行)
マップグリッドのタイルです。他のコンポーネントとは異なり、StatefulWidget で実装しています。
class NeoBrutalMapTile extends StatefulWidget {
const NeoBrutalMapTile({
super.key,
required this.child,
required this.color,
this.onTap,
this.shadowOffset = const Offset(4, 4),
this.borderWidth = 3.0,
this.margin = const EdgeInsets.all(3),
this.badge, // ★ バッジオーバーレイ
});
// ...
}
class _NeoBrutalMapTileState extends State<NeoBrutalMapTile>
with SingleTickerProviderStateMixin {
// ...
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _press,
builder: (context, child) {
// ... 押し込みアニメーション(Button と同じ原理)
return Stack(
clipBehavior: Clip.none, // ★ バッジのはみ出しを許可
children: [
Container(
margin: widget.margin,
decoration: BoxDecoration(
color: widget.color,
borderRadius: BorderRadius.circular(4),
border: Border.all(width: widget.borderWidth, color: cs.onSurface),
boxShadow: [
BoxShadow(color: cs.onSurface, offset: remainingShadow, blurRadius: 0),
],
),
child: child,
),
if (widget.badge != null)
Positioned(top: 0, right: 0, child: widget.badge!),
],
);
},
);
}
}
なぜ HookWidget ではないのか
MapTile はグリッド内で 数十〜数百個 同時にインスタンス化されます。HookWidget は内部でフック登録リストを管理するため、大量生成時に微小なオーバーヘッドが蓄積します。StatefulWidget + SingleTickerProviderStateMixin で AnimationController の生成コストを最小化しました。
バッジのポイント
clipBehavior: Clip.none でバッジがタイルの外にはみ出せるようにしています。バッジ自体もネオ・ブルータリズムの原則に従い、太い枠線 + ソリッドシャドウを持ちます(ただしサイズに応じて控えめに)。
4-5. NeoBrutalStatusBar — ゲージバー(90行)
ステータスバーは NeoBrutalContainer を 使わず、自前で BoxDecoration を構築しています。
class NeoBrutalStatusBar extends StatelessWidget {
const NeoBrutalStatusBar({
super.key,
this.label,
required this.value,
required this.maxValue,
this.color,
this.height = 28.0,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final gaugeColor = color ?? cs.primary;
final ratio = (maxValue > 0 ? value / maxValue : 0.0).clamp(0.0, 1.0);
return Row(
children: [
// ラベル(52px固定幅)
if (label != null) ...[
SizedBox(width: 52, child: Text(label!, style: TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 8),
],
// ゲージ本体
Expanded(
child: Container(
height: height,
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(width: 2.5, color: cs.onSurface),
boxShadow: [
BoxShadow(color: cs.onSurface, offset: const Offset(3, 3), blurRadius: 0),
],
),
clipBehavior: Clip.antiAlias, // ★ ゲージが枠からはみ出さない
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: ratio, // 0.0 〜 1.0
child: Container(
decoration: BoxDecoration(
color: gaugeColor,
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
const SizedBox(width: 8),
// 数値(40px固定幅)
SizedBox(
width: 40,
child: Text('$value', textAlign: TextAlign.right,
style: TextStyle(fontWeight: FontWeight.w900)),
),
],
);
}
}
なぜ Container を使わなかったのか
StatusBar は Row 内にラベル + ゲージ + 数値を横並びにする独自レイアウトを持ちます。NeoBrutalContainer は単一の child を受け取る Box なので、この構造には対応できません。ただし、BoxDecoration のルール(太枠 + blur:0 の影)は同じ です。
実践パターン:ネスト・バッジ・階層設計
ネストのルール
コンテナをネストする場合の3つのルールです。
-
外側の影 > 内側の影: カード
Offset(4, 4)> タグOffset(2, 2) -
内側は異なる色で差別化: カード
cs.surface/ タグcs.primaryContainer -
内側の枠色は親テーマ色に合わせる: タグ
borderColor: cs.primary
階層設計のガイドライン
| 階層 | 要素例 | shadowOffset | borderWidth |
|---|---|---|---|
| Lv1 | ページカード | (4, 4) | 3.0 |
| Lv2 | カード内セクション | (3, 3) | 3.0 |
| Lv3 | セクション内タグ | (2, 2) | 2.5 |
| Lv4 | タグ内バッジ | (2, 2) | 2.0 |
原則:階層が深くなるほど影とボーダーは小さくなる。
カラーシステムとの統合
ネオ・ブルータリズムだからといって Material 3 の ColorScheme を捨てる必要はありません。
ThemeData buildAppTheme(GameFont font) {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
primary: AppColors.primary, // ボタンの背景色
onPrimary: AppColors.textOnPrimary, // ボタンのテキスト色
surface: AppColors.surface, // Container の背景色
onSurface: AppColors.textPrimary, // ★ 枠線・影の色になる
),
);
}
コンポーネントは Theme.of(context).colorScheme を参照するので、テーマを変えるだけで全コンポーネントの見た目が変わります。
-
ライトテーマ:
onSurface≒ 黒 → コントラスト強い枠線・影 -
ダークテーマ:
onSurface≒ 白 → テーマに自然に適応
5. まとめ
ネオ・ブルータリズムUIの実装に必要なのは、突き詰めれば 3つの数値 だけです。
blurRadius: 0 // ソリッドシャドウ
Border.all(width: 3) // 太い枠線
borderRadius: 4 // 控えめな角丸
Politown では、この原則を NeoBrutalContainer に定数化し、そこから Button / IconButton / MapTile / StatusBar の 5コンポーネント を派生させました。全て100行前後と小さく、アプリ全体の視覚的統一感を担っています。
この記事で押さえてほしいこと
| # | ポイント |
|---|---|
| 1 |
blurRadius: 0 がネオ・ブルータリズムの核心 |
| 2 | 枠線と影は 同じ色 にする |
| 3 | 押し込みアニメーションは shadow * t + shadow * (1-t) で合計一定 |
| 4 | 80ms + Curves.easeOut がパキッとした操作感を生む |
| 5 | 階層が深くなるほど影とボーダーは小さくする |
やってはいけない5つのこと
// NG 1: blurRadiusを0以外にする → もはやネオブルではない
BoxShadow(offset: Offset(4, 4), blurRadius: 8)
// NG 2: 枠線を省略する → アイデンティティの喪失
BoxDecoration(color: Colors.yellow, boxShadow: [...]) // borderが無い!
// NG 3: 影と枠線の色を変える → 統一感が崩れる
border: Border.all(color: Colors.black),
boxShadow: [BoxShadow(color: Colors.blue)] // 色が違う!
// NG 4: 角丸を大きくしすぎる → Material Designに見える
borderRadius: BorderRadius.circular(16)
// NG 5: 押し込み方向を影と逆にする → 不自然な動き
Transform.translate(offset: Offset(-shadow.dx * t, ...)) // 逆!
ぜひ5つのコンポーネントを自分のプロジェクトにコピーして、Material Design とは異なる「パキッ」とした触り心地を体験してみてください。
参考資料








