feat: diagonal color wave across icon-picker grid

When the user hovers or commits a swatch, the icons in the picker grid
transition to the new color in a staggered wave from top-left to
bottom-right rather than snapping in lockstep — borrowed from Linear's
icon picker.

Mechanically: each cell renderer (icon-cp / emoji-cp / text-cp /
avatar-cp) stamps its grid position as inline `--r` / `--c` custom
properties, sourced from `pane-section`'s render loop. The existing
`--ls-color-icon-preset` cascade already carries the chosen color
down to the cells via currentColor inheritance; we add `color
320ms cubic-bezier(.4,0,.2,1)` to the per-button transition with
`transition-delay: calc(var(--c) * 22ms + var(--r) * 36ms)`, so
each cell starts its glide on its own clock.

Two design decisions worth flagging:

- **Hover preview drives the wave too**, not just commit. CSS
  transitions hold the old value during their `delay` window and
  retarget cleanly when the property changes mid-flight, so rapid
  cursor sweeps gracefully chase without flicker — far cells just
  hold steady until the user settles.
- **Material's "standard" easing** (`cubic-bezier(0.4, 0, 0.2, 1)`)
  over `ease-out`: each cell pauses, glides through the middle, and
  settles. The pause makes the stagger legible — neighbors are
  visibly "starting" while predecessors are mid-glide, which is
  what reads as a wave rather than a snap-and-fade.

`prefers-reduced-motion: reduce` strips the color transition so the
grid snaps instantly for users who opt out of animation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-04-25 16:42:31 +02:00
parent bb1dd9f029
commit a153d10963
2 changed files with 48 additions and 9 deletions

View File

@@ -1353,7 +1353,7 @@
[:div.its.icons-row items])
(rum/defc icon-cp < rum/static
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id]}]
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id wave]}]
(let [icon-id (get-in icon-item [:data :value])
icon-name (or (:label icon-item) icon-id)
color (get-in icon-item [:data :color])
@@ -1367,6 +1367,7 @@
:class (cond
(= my-id highlighted-id) "is-highlighted"
(= my-id ghost-highlighted-id) "is-ghost-highlighted")
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
:title icon-name
:on-click (fn [e]
(on-chosen e (cond-> {:type :tabler-icon
@@ -1383,7 +1384,7 @@
(ui/icon icon-id' {:size 24}))]))
(rum/defc emoji-cp < rum/static
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id]}]
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id wave]}]
(let [emoji-id (get-in icon-item [:data :value])
emoji-name (or (:label icon-item) emoji-id)
my-id (:id icon-item)]
@@ -1394,6 +1395,7 @@
:class (cond
(= my-id highlighted-id) "is-highlighted"
(= my-id ghost-highlighted-id) "is-ghost-highlighted")
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
:title emoji-name
:on-click (fn [e]
(on-chosen e {:type :emoji
@@ -1408,7 +1410,7 @@
:style {:line-height 1}}]]))
(rum/defc text-cp < rum/static
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id]}]
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id wave]}]
(let [text-value (get-in icon-item [:data :value])
text-color (get-in icon-item [:data :color])
my-id (:id icon-item)
@@ -1422,6 +1424,7 @@
:class (cond
(= my-id highlighted-id) "is-highlighted"
(= my-id ghost-highlighted-id) "is-ghost-highlighted")
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
:title text-value
:on-click (fn [e]
(on-chosen e {:type :text
@@ -1435,7 +1438,7 @@
display-text]))
(rum/defc avatar-cp < rum/static
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id]}]
[icon-item {:keys [on-chosen hover highlighted-id ghost-highlighted-id wave]}]
(let [avatar-value (get-in icon-item [:data :value])
backgroundColor (or (get-in icon-item [:data :backgroundColor])
(colors/variable :gray :09))
@@ -1449,6 +1452,7 @@
{:tabIndex "-1"
:data-item-id my-id
:title avatar-value
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
:class (str "p-0 border-0 bg-transparent cursor-pointer"
(cond
(= my-id highlighted-id) " is-highlighted"
@@ -1571,12 +1575,18 @@
(catch js/Error e
(js/console.error e)
nil))]
(mapv #(render-fn % opts) icons))))}
(vec (map-indexed
(fn [c-idx item]
(render-fn item (assoc opts :wave {:r idx :c c-idx})))
icons)))))}
searching?
(assoc :custom-scroll-parent (some-> (rum/deref *el-ref) (.closest ".bd-scroll"))))))
[:div.its
(map #(render-fn % opts) icon-items)]))]))
(map-indexed
(fn [i item]
(render-fn item (assoc opts :wave {:r (quot i 9) :c (mod i 9)})))
icon-items)]))]))
(rum/defc emojis-cp < rum/static
[emojis* opts]
@@ -3975,7 +3985,11 @@
:on-select! on-select!
:on-hover! on-hover!
:on-hover-end! on-hover-end!}))]
;; Display effect — fires for hover and committed color. Updates CSS var only.
;; Display effect on the picker root — fires for both hover and committed
;; color. Combined with the per-cell `--r`/`--c` `transition-delay` in CSS,
;; every change to the var (hover preview OR commit) plays the diagonal
;; wave across the grid. Rapid hover sweeps gracefully retarget mid-flight
;; because each cell's delay holds its current value until activation.
(hooks/use-effect!
(fn []
(when-let [^js picker (some-> (rum/deref *el) (.closest ".cp__emoji-icon-picker"))]

View File

@@ -129,10 +129,24 @@
transitioned — animating it from `currentColor` (the icon's
tint, e.g. red) would briefly tint the ring red before the
accent color landed. With color set instantly by the highlight
rules, the ring just grows in the right color from t=0. */
rules, the ring just grows in the right color from t=0.
`color` is also transitioned, with a per-cell delay computed
from the inline --r / --c custom properties. When the user
commits a new icon color, --ls-color-icon-preset on the picker
root changes, and each cell's `color` (inherited via
currentColor) glides into the new value at its own delay,
producing a diagonal "wave" across the grid (top-left first,
bottom-right last). Hover preview is intentionally NOT routed
through the CSS var (it goes to the trigger and page header
via :ui/icon-hover-preview state) so the wave has a real
old→new delta to interpolate at commit time. */
transition: background-color 150ms,
outline-width 150ms,
outline-offset 150ms;
outline-offset 150ms,
color 320ms cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 0ms, 0ms, 0ms,
calc(var(--c, 0) * 22ms + var(--r, 0) * 36ms);
&:hover {
background-color: var(--rx-gray-05);
@@ -151,6 +165,17 @@
.virtuoso-item-list {
@apply pt-1 pb-4 px-2;
}
/* Honor reduced-motion: drop the staggered color transition so the
grid snaps to the new color rather than animating across cells. */
@media (prefers-reduced-motion: reduce) {
> .its > button,
.icons-row > button {
transition: background-color 150ms,
outline-width 150ms,
outline-offset 150ms;
}
}
}
.icons .ui__icon {