diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
index cfb61d9a..ce7348e8 100644
--- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml
+++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
@@ -867,19 +867,6 @@
-
-
-
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs
index 98121452..39cf4941 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs
@@ -18,8 +18,6 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
[ObservableProperty] private string _iconKey = "Folder";
/// Hex colour for name + guide line; null = theme default.
[ObservableProperty] private string? _color;
- /// Set on the first user toggle so the expand animation skips folders that load already open.
- [ObservableProperty] private bool _animateExpand;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowCount))]
private bool _isCollapsed;
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
index dc9afa95..d327b1c6 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
@@ -730,7 +730,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
private void ToggleFolder(ListFolderNavItemViewModel? folder)
{
if (folder is null) return;
- folder.AnimateExpand = true;
folder.IsCollapsed = !folder.IsCollapsed;
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
var cts = new CancellationTokenSource();
diff --git a/src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs b/src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs
new file mode 100644
index 00000000..824ccce5
--- /dev/null
+++ b/src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Threading;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Styling;
+
+namespace ClaudeDo.Ui.Views.Controls;
+
+///
+/// Accordion host: animates its height between 0 and the child's natural height, so content
+/// below slides instead of jumping. The first value (from the binding) applies without animation.
+///
+public sealed class ExpandPanel : Decorator
+{
+ private static readonly TimeSpan Duration = TimeSpan.FromMilliseconds(180);
+
+ public static readonly StyledProperty IsExpandedProperty =
+ AvaloniaProperty.Register(nameof(IsExpanded));
+
+ /// 0 = collapsed, 1 = fully open; drives the measured height.
+ public static readonly StyledProperty ProgressProperty =
+ AvaloniaProperty.Register(nameof(Progress));
+
+ private CancellationTokenSource? _cts;
+ private bool _arranged;
+
+ static ExpandPanel()
+ {
+ AffectsMeasure(ProgressProperty);
+ ClipToBoundsProperty.OverrideDefaultValue(true);
+ }
+
+ /// Whether the content is shown.
+ public bool IsExpanded
+ {
+ get => GetValue(IsExpandedProperty);
+ set => SetValue(IsExpandedProperty, value);
+ }
+
+ /// Current open fraction, animated between 0 and 1.
+ public double Progress
+ {
+ get => GetValue(ProgressProperty);
+ set => SetValue(ProgressProperty, value);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == IsExpandedProperty)
+ Animate(IsExpanded ? 1 : 0);
+ else if (change.Property == ProgressProperty && Child is { } child)
+ child.IsVisible = Progress > 0;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ if (Child is { } child) child.IsVisible = Progress > 0;
+ }
+
+ private void Animate(double to)
+ {
+ _cts?.Cancel();
+ var from = Progress;
+ Progress = to;
+ if (!_arranged || from == to) return;
+
+ _cts = new CancellationTokenSource();
+ var animation = new Animation
+ {
+ Duration = Duration,
+ Easing = new CubicEaseOut(),
+ Children =
+ {
+ new KeyFrame { Cue = new Cue(0), Setters = { new Setter(ProgressProperty, from) } },
+ new KeyFrame { Cue = new Cue(1), Setters = { new Setter(ProgressProperty, to) } },
+ },
+ };
+ _ = animation.RunAsync(this, _cts.Token);
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (Child is not { } child) return default;
+ child.Measure(availableSize.WithHeight(double.PositiveInfinity));
+ return new Size(child.DesiredSize.Width, child.DesiredSize.Height * Progress);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ _arranged = true;
+ // Full natural height, top-aligned; ClipToBounds cuts off what isn't revealed yet.
+ Child?.Arrange(new Rect(0, 0, finalSize.Width, Child.DesiredSize.Height));
+ return finalSize;
+ }
+}
diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml
index 077037d5..e0c89532 100644
--- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml
@@ -3,6 +3,7 @@
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:converters="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization"
+ xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel">
-
-
-
-
+
+
+
+
+
+