Luna provides a pure UI Toolkit particle attractor for the classic "fly to" UX pattern: spawn a burst of pooled VisualElement particles at a source element and animate them through a two-stage scatter → home trajectory into a target element. No cameras, no RenderTextures, no ParticleSystem — just live UI Toolkit elements driven by style.translate.
Use it for coin pickups, XP orbs, gem rewards, heart drops, or any "absorb into the HUD" effect.
style.translate — zero camera/RT cost, scales to hundreds of particlesUIAttractorManager lazily creates itself on first call — no scene wiring requiredUIAttractorManager.Burst(...), Inspector-friendly UIAttractorComponent MonoBehaviour, or embed the [Serializable] UIAttractorBurstConfig struct on your own MonoBehaviourUIAttractorTarget registers UI elements under string keys; bursts route to the most recently activated target via LIFOOnArrive(int index) per particle and OnAllArrived onceTime.unscaledDeltaTime so bursts animate during pause; toggle to honor Time.timeScale┌──────────────────────────────────────────────┐
│ UIAttractorManager │
│ (Singleton, frame ticker, particle pool) │
├──────────────────────────────────────────────┤
│ │
│ per panel: ┌───────────────────────────┐ │
│ │ overlay VisualElement │ │
│ │ (auto-promoted at root) │ │
│ └───────────────────────────┘ │
│ │
│ per burst: ┌───────────────────────────┐ │
│ │ UIAttractorBurstConfig (config) │ │
│ │ + N pooled particles │ │
│ └───────────────────────────┘ │
│ │
│ Burst() returns ──► UIAttractorHandle │
└──────────────────────────────────────────────┘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 = UIAttractorManager.Burst(source, target, burst);The first call to Burst() lazily creates the singleton GameObject if it isn't already in the scene — there's nothing to set up.
UIAttractorComponentFor screens that wire bursts via the Inspector instead of code, UIAttractorComponent keeps its surface minimal — the entire motion configuration lives in a single nested Burst field that you expand to tune.
The component's Inspector is organized into three sections:
| Section | Fields |
|---|---|
| Source / Target Resolution | UIViewComponent, UIDocument, Source Name, Target Name, Target Key |
| Burst (foldout) | Template, Count, Phase 1/Phase 2 durations + easings, scatter distances, scale range, spawn jitter, start stagger, target tracking, time mode, random seed |
| Events | OnArrive (int), OnAllArrived |
To set up:
UIAttractorComponent to a GameObject (typically alongside the screen's UIDocument).UIViewComponent or UIDocument and set Source Name plus either Target Name or Target Key (the latter resolves through the registry — see below).VisualTreeAsset), tune durations / easings / per-particle settings.Play() (or Play(int count) to override the Inspector count for one call) from any script that holds a reference.[SerializeField] private UIAttractorComponent _coinAttractor;
void OnPickup(int amount) => _coinAttractor.Play(amount); // count override
void OnPickupRare(int amount) => _coinAttractor.Play(amount, "rare-bag"); // count + key override
void OnTutorialFinish() => _coinAttractor.Play("tutorial-target"); // key override onlyThe component resolves source/target via either a UIViewComponent reference (preferred when you're using Luna's view system) or via a UIDocument fallback — same pattern as TransitionAnimator. When Target Key is set, it takes precedence over Target Name and the burst routes through UIAttractorTargetRegistry.
Play() overloads:
| Signature | Behaviour |
|---|---|
Play() | All Inspector defaults. |
Play(int count) | Count override; everything else from Inspector. |
Play(string targetKey) | Registry key override; pass an empty string to bypass the registry and use Target Name instead. |
Play(int count, string targetKey) | Both overrides. |
Play(VisualElement source, VisualElement target) | Explicit elements; ignores Source Name, Target Name, and Target Key. |
The component exposes public getters for SourceName, TargetKey, TargetName, and Burst so other scripts can read its configuration without duplicating it (e.g. a controller looking up the same source button by name).
UIAttractorBurstConfig on your own MonoBehaviourUIAttractorBurstConfig is [Serializable] — you can drop it as a [SerializeField] on any MonoBehaviour and skip UIAttractorComponent entirely. This is useful when the burst is one of several knobs on a custom screen controller and you don't want a second component:
public class CoinPickupScreen : MonoBehaviour
{
[SerializeField] private UIDocument _doc;
[SerializeField] private UIAttractorBurstConfig _coinBurst;
public void OnCoinsCollected(int amount)
{
VisualElement source = _doc.rootVisualElement.Q("pickup-zone");
_coinBurst.Count = amount;
UIAttractorManager.Burst(source, "gold", _coinBurst);
}
}Inspector for _coinBurst shows the same nested foldout with all motion knobs. Runtime-only fields (OnArrive, OnAllArrived, HostOverride) are [NonSerialized] and stay code-only.
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").UIDocument or UIViewComponent and set Element Name.On OnEnable the resolved element is pushed to the top of the stack for its key; on OnDisable it is removed. No code wiring needed.
// Manual control if you don't want the OnEnable hook
public UIAttractorTarget _goldTarget;
void OpenShop() => _goldTarget.Activate();
void CloseShop() => _goldTarget.Deactivate();UIAttractorManager.Burst(source, "gold", burst);If multiple targets are registered for "gold", the most recently activated one wins. If none is currently registered, the manager logs a warning and the burst is skipped. Bursts via key automatically follow the active target as registrations come and go.
The same routing happens transparently when a UIAttractorComponent has its Target Key field set — Play() fires through the registry.
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 manager 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.
UIAttractorManager 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.
| 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. |
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 an OnFadeInComplete 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 bursts continue during pause menus. Set UseUnscaledTime = false on the burst (or on UIAttractorComponent) to honor Time.timeScale.worldBound. If you call Burst() on an element whose layout hasn't run yet, the manager logs a warning and skips the burst. 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).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