NativeDesktop

Motion

Platform timings for every animation, hover groups that reveal actions, shared element transitions, presentations, and the lyrics depth of field.

Every animation in nd goes through one set of platform timings. The theme's preset carries motion (fast, normal and slow durations, the platform's curve and its spring), and the host copies it into the app's MotionTokens. An element asks for a pace rather than a duration, and gets the platform's timing for it:

PaceUsemacOSGNOMEWindows
Animation::fast()hover, press, small state changes150 ms200 ms83 ms
Animation::normal()something coming in or going out250 ms250 ms167 ms
Animation::slow()larger moves400 ms400 ms250 ms
Animation::natural()interruptible moves, the platform springresponse 0.35response 0.2response 0.3

The curve is CAMediaTimingFunction's default on macOS, ease-out-cubic on GNOME and WinUI's entrance curve on Windows. With reduce motion on, every paced animation becomes a short fade: offsets, scales and sizes jump, and opacity still eases.

div()
    .opacity(if shown { 1. } else { 0. })
    .animate(Animation::normal())

Animation::timed(..) and Animation::spring(..) still take explicit values, for motion that has a reason to differ from the platform. ND_ANIMATION_SLOWDOWN=8 stretches every paced animation eight times, to watch one frame by frame.

Hover groups

A card or a row is a hover group. While the pointer is anywhere over it, descendants that ask for .group_hover(name, ..) take their hover style, easing in and out on Animation::fast(). .reveal_on_hover(name) is the common case: hidden at rest, faded in on hover.

div()
    .group("card")
    .child(artwork)
    .child(div().reveal_on_hover("card").child(icon_button("card.more", Icon::Ellipsis)))

The group name is looked up among the group's own descendants, so every card can use the same name. Revealed controls stay in the accessibility tree at rest, so a screen reader reaches them without a pointer.

Actions over artwork

artwork_actions draws Music's hover: the artwork darkens and Play and More fade in over its bottom corners. Put it in a box with the artwork that clips to the artwork's corners. On a row, .compact() puts the Play glyph alone in the middle of the small artwork, and hover_menu_button adds More at the end of the row.

let art = div()
    .relative()
    .rounded_lg()
    .overflow_hidden()
    .child(image(cover).size(px(168.)))
    .child(
        artwork_actions("album.actions", "card")
            .on_play(move |_, cx| play(cx))
            .menu(move |menu, _, cx| album_menu(menu, cx)),
    );
v_flex().group("card").child(art).child(label(title))

Shared element transitions

.hero_from(id, away) makes an element travel from the frame of the element keyed id when it mounts, and back onto that frame while away is true. It runs on the platform spring, so a new target mid-flight turns it around from where it is. Its size animates when given in points, so give it a box of its final size and place it absolutely in that box, and nothing else moves:

div().size(px(480.)).child(
    div()
        .absolute()
        .top_0()
        .left_0()
        .size(px(480.))
        .hero_from("player.bar.art", closing)
        .child(cover.size_full()),
)

The element shows nothing on its first frame, while its own frame is laid out. Then it sits on the source for the fast pace and travels. With reduce motion on, it appears in place.

Presentations

presentation shows a view over a region: an expanded player over the page, a detail over its list. The view keeps a Presentation handle and opens or closes it. The base stays mounted, so it keeps its scroll position, and is hidden while the presentation covers it.

The motion is built around a hero inside the content, away set from is_closing(). Opening, the backdrop fades in on the platform spring while the hero travels out of its source, and the rest of the content, marked .reveal_with(&state), fades in once the hero has landed (Pace::Settled). Closing runs backwards: the revealed content fades first, then the hero travels home while the backdrop fades out with it (Pace::Exit). The hero is never half transparent and nothing draws over it in flight. Closing mid-open turns everything around from where it is.

// In the view: self.expanded = Presentation::new();
self.expanded.set_open(true);

presentation("now-playing", &self.expanded)
    .base(page)
    .backdrop(image(cover).size_full().blur(px(64.)))
    .content(self.now_playing.clone())
    .bg(cx.theme().surface.base)

image(..).blur(radius) decodes the picture at eight pixels per radius, blurs it with each edge pixel standing in for what lies past it, and draws it a little past its box on every side, so the wash keeps the picture's colours right up to the box's edges on every backend. Lay a scrim of a surface token over it for text.

Player layout

player_layout lays out an expanded player for the room it is given. From 760 points wide the artwork and the details sit in a column on the left and the panel (lyrics, the queue) on the right; narrower, a header row holds a 64 point artwork beside the details, above the panel. The artwork gets its side in points from the layout, so it never leaves its column and can be a hero.

// In the view: self.layout = PlayerLayoutState::new();
player_layout("now-playing", &self.layout)
    .artwork(move |side| cover.size(px(side)))
    .details(v_flex().child(label(title)).child(label(artist)))
    .panel(lyrics)

Lyrics depth of field

lyrics_view keeps the line being sung sharp and whole, and fades and blurs the others more the further they are from it (DEPTH_OPACITY, DEPTH_BLUR). As the line advances, each row eases to its new depth on the slow pace. While the user scrolls, every row is sharp and whole; resync brings the depth back. The app only supplies the lines and the clock.

On macOS each row's layer carries a Core Image gaussian blur whose radius animates. GPUI has no blur filter for text, so on Linux and Windows a blurred row is drawn as faint copies on a ring of the blur's radius, which add up to the row's opacity with soft edges. Only rows on screen get the copies.