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:
LunaUIManager.Instance.Attractor.Burst(coinIcon, goldCounter, new UIAttractorBurstConfig {
Template = coinParticleVTA, // any VisualTreeAsset can be a particle
Count = 12
});style.translate — zero camera/RT cost, scales to hundreds of particlesUIAttractorMotionSO captures the motion knobs once as a project asset; the UIAttractorTarget registry routes bursts to whichever HUD counter is currently liveLunaUIManager — reach it via LunaUIManager.Instance.Attractor, with per-particle/completion events and pause-friendly unscaled time┌──────────────────────────────────────────────┐
│ 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.
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);UIAttractorMotionSOThe 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:
[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:
UIAttractorBurstConfig burst = _motion.ToBurstConfig(_coinParticle, amount);
burst.OnAllArrived = () => counterPulse.Play(); // callbacks are per-fire, runtime-only
LunaUIManager.Instance.Attractor.Burst(source, "gold", burst);CurrencySO patternThe 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:
// 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;
}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.
UIAttractorBurstConfig on your own MonoBehaviourUIAttractorBurstConfig 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):
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.
All four overloads live on the UIAttractor instance at LunaUIManager.Instance.Attractor:
| Overload | Behaviour |
|---|---|
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| Member | Purpose |
|---|---|
int TotalParticles | Particles initially spawned by the burst. |
int ParticlesAlive | Particles still in flight. Reaches 0 when the burst completes. |
bool IsActive | True while at least one particle is in flight. |
UnityEvent<int> OnArrive | Fires once per particle on arrival; argument is the particle index. |
UnityEvent OnAllArrived | Fires 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.
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.
UIAttractorTargetAdd a UIAttractorTarget MonoBehaviour next to any HUD:
"gold").UIViewComponent or a PanelRenderer (falls back to GetComponent / GetComponentInParent) and set Element Name (empty = the root element).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.
// Manual control if you don't want the OnEnable hook
public UIAttractorTarget _goldTarget;
void OpenShop() => _goldTarget.Activate();
void CloseShop() => _goldTarget.Deactivate();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.
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.
<!-- Particle template — single styled dot -->
<engine:UXML xmlns:engine="UnityEngine.UIElements">
<Style src="MyParticles.uss"/>
<engine:VisualElement class="my-coin-particle"/>
</engine:UXML>/* 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.
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:
.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.
The total burst duration is ScatterDuration + HomeDuration. For each particle:
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).
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:
Snapshot is cheaper and deterministic — prefer it for fixed HUDs.
The attractor maintains two pools so steady-state bursts allocate nothing:
VisualTreeAsset — cloned templates are detached and pushed back on arrival, then re-parented and reused on the next burst with the same template.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.
These fields live on UIAttractorBurstConfig; UIAttractorMotionSO mirrors every motion field (everything except Template, Count, and the runtime-only callbacks) with the same defaults.
| Field | Default | Description |
|---|---|---|
Template | — | UXML cloned per particle. Required. |
Count | 12 | Particles to spawn. |
ScatterDuration | 0.25 | Phase-1 duration in seconds. |
ScatterEasing | QuadOut | Phase-1 easing. |
ScatterMinDistance | 60 | Lower bound of scatter radius. |
ScatterMaxDistance | 140 | Upper bound of scatter radius. |
HomeDuration | 0.55 | Phase-2 duration in seconds. |
HomeEasing | QuadIn | Phase-2 easing. |
ScaleRange | (0.85, 1.15) | Per-particle uniform scale range. |
StartStagger | 0.05 | Max random delay added per particle (seconds). |
SpawnJitter | 0 | Per-particle spawn position jitter radius (pixels). 0 = all spawn at exact source center; larger values let spawns spread within a disc and overlap. |
LiveTargetTracking | false | Re-sample target each frame instead of snapshotting at start. |
UseUnscaledTime | true | Use Time.unscaledDeltaTime (continues during pause). Set false to honor Time.timeScale. |
RandomSeed | 0 | 0 = system random; non-zero = deterministic. |
OnArrive | null | Action<int> invoked per particle on arrival. |
OnAllArrived | null | Action invoked once all particles arrived (or on cancel). |
HostOverride | null | Optional VisualElement host that overrides the default panel-root overlay. |
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.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.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.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.unscaledDeltaTime so they continue during pause menus. Set UseUnscaledTime = false on the burst config (or the motion preset) to honor Time.timeScale.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).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)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction