Coins that scatter and fly into the gold counter. XP orbs absorbed by the level bar. Hearts streaming to the health HUD. UI Attractor delivers the classic "fly to" collect effect in pure UI Toolkit: a burst of pooled VisualElement particles spawns at a source element and homes into a target through a two-stage scatter → home trajectory. No cameras, no RenderTextures, no ParticleSystem — just live UI Toolkit elements driven by style.translate.

One call fires a burst:

csharp
LunaUIManager.Instance.Attractor.Burst(coinIcon, goldCounter, new UIAttractorBurstConfig { Template = coinParticleVTA, // any VisualTreeAsset can be a particle Count = 12 });

Features

  • Pure UI Toolkit: pooled VisualElements driven by style.translate — zero camera/RT cost, scales to hundreds of particles
  • Two-stage motion: configurable scatter duration/easing/distance, then home-in duration/easing — with per-particle randomization (scale range, scatter angle, spawn jitter, start-time stagger)
  • Any UXML is a particle: a styled dot, an icon, a composite icon + label cluster — anything UITK can render, auto-pooled per template
  • Shareable motion presets + string-keyed targets: UIAttractorMotionSO captures the motion knobs once as a project asset; the UIAttractorTarget registry routes bursts to whichever HUD counter is currently live
  • Zero bootstrap: created and ticked by LunaUIManager — reach it via LunaUIManager.Instance.Attractor, with per-particle/completion events and pause-friendly unscaled time

Architecture

┌──────────────────────────────────────────────┐ │ LunaUIManager.Instance.Attractor │ │ (UIAttractor — frame ticker, pools) │ ├──────────────────────────────────────────────┤ │ │ │ per panel: ┌───────────────────────────┐ │ │ │ overlay VisualElement │ │ │ │ (auto-promoted at root) │ │ │ └───────────────────────────┘ │ │ │ │ per burst: ┌───────────────────────────┐ │ │ │ UIAttractorBurstConfig │ │ │ │ + N pooled particles │ │ │ └───────────────────────────┘ │ │ │ │ Burst() returns ──► UIAttractorHandle │ └──────────────────────────────────────────────┘

UIAttractor is not a MonoBehaviour and does not bootstrap itself. LunaUIManager constructs it in Awake and drives it from the manager's single Update, so Time.deltaTime is read once per frame and shared across Luna subsystems. No LunaUIManager in the scene = no attractor.

Each frame, every active particle advances along its scatter point (random direction within the scatter radius range) and then toward the target rect center. Each particle has its own randomized scale and an optional start-time stagger so arrivals don't all land on the same frame.

Quick Start

Code-first

csharp
using CupkekGames.Luna; using CupkekGames.Fadeables; // for EasingType VisualElement source = root.Q<VisualElement>("coin-btn"); VisualElement target = root.Q<VisualElement>("counter"); UIAttractorBurstConfig burst = new UIAttractorBurstConfig { Template = coinParticleVTA, // a VisualTreeAsset Count = 12, ScatterDuration = 0.25f, ScatterEasing = EasingType.QuadOut, ScatterMinDistance = 60f, ScatterMaxDistance = 140f, HomeDuration = 0.55f, HomeEasing = EasingType.QuadIn, OnArrive = i => coinCount++, // per-particle absorb tick OnAllArrived = () => counterPulse.Play() }; UIAttractorHandle handle = LunaUIManager.Instance.Attractor.Burst(source, target, burst);

Motion presets — UIAttractorMotionSO

The motion knobs (durations, easings, distances, scale range, stagger…) are usually shared across every burst in the game, while what flies and where it lands change per fire site. UIAttractorMotionSO is a ScriptableObject that captures the motion once — create it via Create > Luna > UI Attractor > Motion — so one asset (e.g. "RewardRush") drives coin, gem, and currency bursts alike.

Fire with the 5-argument Burst overloads — preset plus per-fire particle, count, and destination:

csharp
[SerializeField] private UIAttractorMotionSO _motion; [SerializeField] private VisualTreeAsset _coinParticle; void OnPickup(VisualElement source, int amount) { UIAttractorHandle handle = LunaUIManager.Instance.Attractor.Burst( source, "gold", _coinParticle, amount, _motion); }

These overloads also compose the shared burst SFX via LunaUIManager.Instance.AudioHandler.PlayAttractor() on success — the raw UIAttractorBurstConfig overloads leave audio to the caller.

Under the hood they call motion.ToBurstConfig(template, count), which stamps a fresh per-fire UIAttractorBurstConfig from the preset — the shared asset is never mutated. Call it yourself when you need to attach callbacks:

csharp
UIAttractorBurstConfig burst = _motion.ToBurstConfig(_coinParticle, amount); burst.OnAllArrived = () => counterPulse.Play(); // callbacks are per-fire, runtime-only LunaUIManager.Instance.Attractor.Burst(source, "gold", burst);

The showcase CurrencySO pattern

The imported Showcase sample (Samples~/Showcase/Components/UIAttractor/) pairs one shared motion preset with one small ScriptableObject per resource that carries the per-currency halves — what flies and where it lands:

csharp
// Sample code — "currency" is a game-domain concept, so this lives in // your project, not in Luna runtime. The attractor stays currency-agnostic. [CreateAssetMenu(menuName = "Showcase/Currency", fileName = "Currency")] public class CurrencySO : ScriptableObject { public VisualTreeAsset Particle; // what flies public string TargetKey; // where it lands (UIAttractorTargetRegistry key) public Sprite Icon; // for chips / labels public string DisplayName; }
csharp
UIAttractorHandle handle = LunaUIManager.Instance.Attractor.Burst( source, currency.TargetKey, currency.Particle, count, _motion); if (handle == null) return; handle.OnArrive.AddListener(_ => { wallet.Add(currency, 1); // per-particle absorb tick });

The sample's UIAttractorDemo scene wires three currencies (coin / gem / heart) through a single motion asset this way, incrementing each HUD counter per arrival.

Embed UIAttractorBurstConfig on your own MonoBehaviour

UIAttractorBurstConfig is [Serializable] — drop it as a [SerializeField] on any MonoBehaviour to expose every motion knob in the Inspector. On a view backed by a UIViewComponent, query the source element in the OnUILoaded hook (the visual tree arrives asynchronously — never in Awake):

csharp
public class CoinPickupScreen : UIViewComponent { [SerializeField] private UIAttractorBurstConfig _coinBurst; private VisualElement _pickupZone; protected override void OnUILoaded(VisualElement root) { _pickupZone = root.Q("pickup-zone"); } public void OnCoinsCollected(int amount) { _coinBurst.Count = amount; LunaUIManager.Instance.Attractor.Burst(_pickupZone, "gold", _coinBurst); } }

Runtime-only fields (OnArrive, OnAllArrived, HostOverride) are [NonSerialized] and stay code-only. Clone() returns a shallow copy when you want to override a single field per call without mutating the serialized original.

Burst API

All four overloads live on the UIAttractor instance at LunaUIManager.Instance.Attractor:

OverloadBehaviour
Burst(VisualElement source, VisualElement target, UIAttractorBurstConfig burst)Explicit elements, full config. No SFX.
Burst(VisualElement source, string targetKey, UIAttractorBurstConfig burst)Target resolved through UIAttractorTargetRegistry. No SFX.
Burst(VisualElement source, string targetKey, VisualTreeAsset particle, int count, UIAttractorMotionSO motion)Registry target, config stamped from the preset. Plays AudioHandler.PlayAttractor() on success.
Burst(VisualElement source, VisualElement target, VisualTreeAsset particle, int count, UIAttractorMotionSO motion)Explicit target, preset config + SFX.

Each returns a UIAttractorHandle, or null when the burst is skipped (null/missing template, no registered target for the key, zero worldBound, count ≤ 0).

UIAttractorHandle

MemberPurpose
int TotalParticlesParticles initially spawned by the burst.
int ParticlesAliveParticles still in flight. Reaches 0 when the burst completes.
bool IsActiveTrue while at least one particle is in flight.
UnityEvent<int> OnArriveFires once per particle on arrival; argument is the particle index.
UnityEvent OnAllArrivedFires once after every particle arrived (or the burst was cancelled).
void Cancel()Cancels immediately — in-flight particles are removed without firing OnArrive; OnAllArrived still fires once.

The Action callbacks on UIAttractorBurstConfig (OnArrive / OnAllArrived) fire alongside the handle events — use the config callbacks for code-first wiring and the handle's UnityEvents when the subscriber only holds the returned handle.

Target Registry

For fly-to UX in apps with multiple HUDs, modals, or scenes, the target element you want particles to land on changes over time — the gold counter on the main HUD, then the gold counter on the shop overlay when it opens, then back. Hard-coding VisualElement references doesn't scale.

UIAttractorTargetRegistry is a global LIFO registry mapping string keys to live targets. The most recently activated registration wins; when it deactivates, the next-most-recent resurfaces automatically.

Registering with UIAttractorTarget

Add a UIAttractorTarget MonoBehaviour next to any HUD:

  1. Set Key (e.g. "gold").
  2. Reference a UIViewComponent or a PanelRenderer (falls back to GetComponent / GetComponentInParent) and set Element Name (empty = the root element).
  3. Leave Activate On Enable checked (default).

On OnEnable the resolved element is pushed to the top of the stack for its key; on OnDisable it is removed. Because PanelRenderer delivers its visual tree asynchronously, the component registers a reload callback and re-activates automatically once the tree arrives — no code wiring needed either way.

csharp
// Manual control if you don't want the OnEnable hook public UIAttractorTarget _goldTarget; void OpenShop() => _goldTarget.Activate(); void CloseShop() => _goldTarget.Deactivate();

Firing a burst at a key

csharp
LunaUIManager.Instance.Attractor.Burst(source, "gold", burst);

If multiple targets are registered for "gold", the most recently activated one wins. If none is currently registered, the attractor logs a warning and the burst is skipped. Bursts via key automatically follow the active target as registrations come and go.

Particle Templates

A particle is just a VisualTreeAsset. Anything UI Toolkit can render is fair game: a single styled VisualElement, an Image, a composite "icon + amount" cluster — whatever you need. The attractor clones the template once per particle, parents the clone into a per-panel overlay container, and animates style.translate and style.scale each frame.

xml
<!-- Particle template — single styled dot --> <engine:UXML xmlns:engine="UnityEngine.UIElements"> <Style src="MyParticles.uss"/> <engine:VisualElement class="my-coin-particle"/> </engine:UXML>
css
/* MyParticles.uss */ .my-coin-particle { width: 16px; height: 16px; border-radius: 8px; margin-left: -8px; /* recenter the box on the translate point */ margin-top: -8px; background-color: #f5c542; border-width: 2px; border-color: #b78a17; }

The margin-left/-top of half-the-size pattern recenters the element on its translate origin so particles visually center on the source/target, not anchor at top-left.

Opacity fade-in (optional)

Each particle is spawned with inline style.opacity = 0 and flipped to 1 the first frame its start delay ends — i.e. the moment scatter motion begins. This means every particle is invisible while waiting (so a staggered burst doesn't show a frozen pile of dots at the source).

To make the flip animate as a fade-in instead of snapping, add a vanilla USS transition on your particle class:

css
.my-coin-particle { /* ...visual styles... */ transition-property: opacity; transition-duration: 0.15s; transition-timing-function: ease-out; }

Without the transition rule, particles still appear at the right moment but pop into view.

Motion Model

The total burst duration is ScatterDuration + HomeDuration. For each particle:

  • Phase 1 (Scatter): lerp from the source center to a per-particle scatter point, easing applied to phase-local progress.
  • Phase 2 (Home): lerp from the scatter point to the live or snapshot target center, easing applied to phase-local progress.

The scatter point is sourceCenter + dir * Random.Range(min, max) where dir is uniformly sampled on the unit circle. There is no directional bias by default — particles spray outward in every direction before being sucked in. Pass a RandomSeed for deterministic playback (useful for tests or replay-style effects).

Target Tracking

By default the target rect is snapshotted at burst start. Set LiveTargetTracking = true to re-sample the target each frame. Live tracking is the right choice when:

  • The target HUD itself animates in or pulses while the burst is in flight.
  • The screen layout might shift mid-burst (resize, panel pop-in).

Snapshot is cheaper and deterministic — prefer it for fixed HUDs.

Pooling

The attractor maintains two pools so steady-state bursts allocate nothing:

  • VisualElement pool, keyed by VisualTreeAsset — cloned templates are detached and pushed back on arrival, then re-parented and reused on the next burst with the same template.
  • Particle state pool — internal UIAttractorParticle POCOs that hold per-particle motion state are recycled rather than re-allocated.

Both pools grow on demand and have no hard cap. The first burst of a given template allocates; every subsequent burst with the same template reuses pooled instances.

Settings Reference

These fields live on UIAttractorBurstConfig; UIAttractorMotionSO mirrors every motion field (everything except Template, Count, and the runtime-only callbacks) with the same defaults.

FieldDefaultDescription
TemplateUXML cloned per particle. Required.
Count12Particles to spawn.
ScatterDuration0.25Phase-1 duration in seconds.
ScatterEasingQuadOutPhase-1 easing.
ScatterMinDistance60Lower bound of scatter radius.
ScatterMaxDistance140Upper bound of scatter radius.
HomeDuration0.55Phase-2 duration in seconds.
HomeEasingQuadInPhase-2 easing.
ScaleRange(0.85, 1.15)Per-particle uniform scale range.
StartStagger0.05Max random delay added per particle (seconds).
SpawnJitter0Per-particle spawn position jitter radius (pixels). 0 = all spawn at exact source center; larger values let spawns spread within a disc and overlap.
LiveTargetTrackingfalseRe-sample target each frame instead of snapshotting at start.
UseUnscaledTimetrueUse Time.unscaledDeltaTime (continues during pause). Set false to honor Time.timeScale.
RandomSeed00 = system random; non-zero = deterministic.
OnArrivenullAction<int> invoked per particle on arrival.
OnAllArrivednullAction invoked once all particles arrived (or on cancel).
HostOverridenullOptional VisualElement host that overrides the default panel-root overlay.

Gotchas

  • LunaUIManager must be in the scene. The attractor is not auto-bootstrapped — it is created and ticked by the manager. LunaUIManager.Instance.Attractor is null-territory in a scene without the manager.
  • Particle anchoring. The attractor sets style.translate to the world-to-local position of the target rect center. The element box still anchors at left: 0; top: 0. Recenter visually with negative margins equal to half the particle's size (the margin-left: -8px; margin-top: -8px pattern above). Otherwise particles will appear shifted by half their box.
  • Source/target must be attached. Both elements must be in a live panel when Burst() is called — worldBound is unreliable on detached elements. If your UI is gated by a fade-in, fire the burst from inside a fade-in-complete callback or a schedule.Execute(...) deferred call.
  • Cancellation still fires OnAllArrived. handle.Cancel() removes any in-flight particles and fires OnAllArrived once so callers can rely on it as a single completion signal. Per-particle OnArrive is not fired for cancelled particles.
  • Time scale. Bursts default to Time.unscaledDeltaTime so they continue during pause menus. Set UseUnscaledTime = false on the burst config (or the motion preset) to honor Time.timeScale.
  • Zero worldBound. If you call Burst() on an element whose layout hasn't run yet, the attractor logs a warning and skips the burst (returns null). Defer the call until after layout (a click handler, schedule.Execute, or post-fade-in callback).

See Also

  • UIRender — for full Unity ParticleSystem content rendered onto UI Toolkit (when you need GPU particles, trails, or VFX Graph effects rather than icon-fly-to UX).
  • Transition Animation — for sequenced UI tweens (scale, opacity, translate) that compose well with bursts (e.g. OnAllArrived → bump counter via a transition preset).
  • LunaUIManager — the manager that owns and ticks the attractor.

UI Attractor ships with Luna — get it on the Asset Store.

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL