One of the most frustrating things about Unity is how fractured the documentation and support has gotten in 2026. It feels like you need to follow Unity (and not just Unity, but the various product teams specifically!) across multiple platforms just to have a hope of staying informed — and even then, you can be blindsided randomly by new announcements, features, or known issues. I just went to grab a sample article about Unity’s changes about GDC this year and instead learned that they also announced at GDC that official Steam support is coming to Unity soon. (The flipside, I guess, is that nothing is ever really a dead idea, since you may stumble on the existing solution some day. 🤷♂️)
For Click Crafter, I caused myself a great deal of frustration trying to design a Software Cursor using UI Toolkit — much of which could have been avoided, had I known Unity’s New Input System already comes with a Software Cursor implementation! It’s targeted against the UGUI package, but Unity’s VirtualMouseInput.cs proved very easy to modify to work with UI Toolkit. The code below is running from a DOTS system, targeting a UI Toolkit document; but you could just as easily use OnEnable() and InputSystem.onAfterUpdate like VirtualMouseInput.cs in order to update the Software Cursor position each frame.
protected override void OnUpdate(){ // get existing control data, process, then update blackboard with new values var playerControlData = latiosWorld.sceneBlackboardEntity.GetComponentData<PlayerControlData>(); var scw = latiosWorld.sceneBlackboardEntity.GetComponentData<WindowRefSoftwareCursor>().WindowReference.Value; UpdateCursorPosition(ref playerControlData, in scw); latiosWorld.sceneBlackboardEntity.SetComponentData(playerControlData);}private bool Vector2EffectivelyAtZero(Vector2 vector2){ return Mathf.Approximately(0, vector2.x) && Mathf.Approximately(0, vector2.y);}private void UpdateCursorPosition(ref PlayerControlData playerControlData, in SoftwareCursorWindow scw){ var hwDelta = GlobalInput.HardwareCursor?.delta.ReadValue(); var stickDelta = GlobalInput.StickDeltaAction.ReadValue<Vector2>(); var position = GlobalInput.SoftwareCursor.position.ReadValue(); var currentTime = InputState.currentTime; // no hw cursor or no input; try sw if (!hwDelta.HasValue || Vector2EffectivelyAtZero(hwDelta.Value)) { // Motion has stopped if (Vector2EffectivelyAtZero(stickDelta)) { playerControlData.LastCursorTime = 0; playerControlData.LastCursorDelta = default; } else { // Motion has started. if (Vector2EffectivelyAtZero(playerControlData.LastCursorDelta)) playerControlData.LastCursorTime = currentTime; // Compute delta. var deltaTime = (float)(currentTime - playerControlData.LastCursorTime); var computedDelta = new Vector2(playerControlData.CursorSpeed * stickDelta.x * deltaTime, playerControlData.CursorSpeed * stickDelta.y * deltaTime); // Update position; clamp to screen position += computedDelta; position.x = Mathf.Clamp(position.x, 0, Screen.width); position.y = Mathf.Clamp(position.y, 0, Screen.height); // update sw cursor; sync HW (if exists) InputState.Change(GlobalInput.SoftwareCursor.position, position); InputState.Change(GlobalInput.SoftwareCursor.delta, computedDelta); GlobalInput.HardwareCursor?.WarpCursorPosition(position); playerControlData.LastCursorTime = currentTime; playerControlData.LastCursorDelta = stickDelta; } } // otherwise, use hw input else { var mouseDelta = hwDelta.Value; position = GlobalInput.HardwareCursor.position.ReadValue(); InputState.Change(GlobalInput.SoftwareCursor.position, position); InputState.Change(GlobalInput.SoftwareCursor.delta, mouseDelta); playerControlData.LastCursorTime = currentTime; playerControlData.LastCursorDelta = mouseDelta; } // calculate viewport position and save, then update UI software cursor playerControlData.CursorViewportPosition = new float2(position.x / Screen.width, position.y / Screen.height); scw.UpdateScreenPosition(playerControlData.CursorViewportPosition);}
Because ReadPlayerInput.cs is a DOTS SubSystem (a Latios Framework subclassing of SystemBase), we are using a IComponentData struct that is read at the start of each system frame (before being written back at the end) to store our PlayerControlData (shown below).
Each frame, the above code first attempts to read a mouse cursor delta from the system’s hardware mouse; if the system has no hardware mouse, or the delta is (effectively) zero, it then attempts the same check with the software mouse cursor, using the defined StickDelta from the project’s Input Action settings.

(Important note: the StickDelta Input Action should NOT include the Hardware Mouse delta!)
public struct PlayerControlData : IComponentData{ public float2 CursorViewportPosition; public float2 LastCursorDelta; public double LastCursorTime; public float CursorSpeed;}
(You can call GlobalInput.cs’ Initialize() function in Awake() below, or somewhere else equivalent)
public static class GlobalInput{ private const string VirtualMouseName = "VirtualMouse"; private static Mouse s_hardwareCursor, s_softwareCursor; public static InputAction StickDeltaAction { get; private set; } public static bool Initialized { get; private set; } public static Mouse SoftwareCursor { get { if (s_softwareCursor is not { added: true, enabled: true }) { TryInitSoftwareMouse(); } return s_softwareCursor; } } public static Mouse HardwareCursor { get { if (s_hardwareCursor is not { enabled: true }) { TryInitHardwareMouse(); } return s_hardwareCursor; } } private static void TryInitHardwareMouse() { var devices = InputSystem.devices; foreach (var device in devices) { if (!device.native || device is not Mouse mouse) continue; s_hardwareCursor = mouse; break; } if (s_hardwareCursor == null) return; Debug.Log($"HardwareCursor initialized: {s_hardwareCursor.displayName}"); } private static void TryInitSoftwareMouse() { // search existing virtual mouse var devices = InputSystem.devices; foreach (var device in devices) { if (device.layout != VirtualMouseName || device is not Mouse mouse) continue; s_softwareCursor = mouse; break; } // none found, add one if (s_softwareCursor == null) s_softwareCursor = (Mouse)InputSystem.AddDevice(VirtualMouseName); else if (!s_softwareCursor.added) InputSystem.AddDevice(s_softwareCursor); if (s_softwareCursor == null) return; Debug.Log($"SoftwareCursor initialized: {s_softwareCursor.displayName}"); } private static void SetupDeviceModeSubscription() { // look for device changes every time there's input InputSystem.onEvent.Call(eventPtr => { var device = InputSystem.GetDeviceById(eventPtr.deviceId); if (LastDevice == device) return; if (eventPtr.type == StateEvent.Type) { // Go through the changed controls in the event and look for ones actuated // above a magnitude of a little above zero. (skip PS controller gyro noise) if (!eventPtr.EnumerateChangedControls(device, 0.0001f).Any()) return; } LastDevice = device; if (device is Gamepad) { // handle gamepad } if (device is Keyboard) { // handle keyboard } if (device is Mouse and { native: true }) { // handle (hw) mouse } }); if (LastDevice == null) return; Debug.Log($"DeviceMode initialized: {LastDevice.displayName}"); } public static void Initialize() { var gameplayMap = InputSystem.actions.FindActionMap("Gameplay"); Debug.Log($"Initializing Global Input\n{gameplayMap} ({gameplayMap.actions.Count})"); StickDeltaAction = gameplayMap.FindAction("StickDelta"); TryInitHardwareMouse(); TryInitSoftwareMouse(); SetupDeviceModeSubscription(); Initialized = true; }}
Attach SoftwareCursorWindow.cs to a GameObject with a UIDocument; point it at SoftwareCursor.uxml (and ensure the accompanying SoftwareCursor.uss exists)

VisualELement _cursorContainer, _cursorElement, _inventoryContainer;private void Awake(){ var rootElement = GetComponent<UIDocument>().rootVisualElement; _cursorContainer = rootElement.Q<VisualElement>("cursor-container"); _cursorElement = rootElement.Q<Image>("cursor-image"); _inventoryContainer = rootElement.Q<VisualElement>("inventory");}public void ShowHideSoftwareCursorExtras(bool show){ // ensure HW cursor is hidden and unlocked Cursor.visible = false; Cursor.lockState = CursorLockMode.None; _cursorContainer.style.visibility = Visibility.Visible; _inventoryContainer.style.visibility = show ? Visibility.Visible : Visibility.Hidden;}/// <summary>/// Calculate / Update Software Cursor's Left and Bottom attributes using container's current Layout size/// </summary>/// <param name="viewportPosition">the position of the Mouse cursor input, in viewport coords</param>public void UpdateScreenPosition(float2 viewportPosition){ _cursorScreenPosition = new float2(viewportPosition.x * _cursorContainer.resolvedStyle.width, viewportPosition.y * _cursorContainer.resolvedStyle.height); // use the cursor image's height to offset the vertical position var screenPosition = new float2(_cursorScreenPosition.x, _cursorScreenPosition.y - _cursorElement.resolvedStyle.height); if (_cursorElement.style.left == screenPosition.x && _cursorElement.style.bottom == screenPosition.y) return; _cursorElement.style.left = screenPosition.x; _cursorElement.style.bottom = screenPosition.y; // move "attached" inventory container too _inventoryContainer.style.left = screenPosition.x + _cursorElement.resolvedStyle.width; _inventoryContainer.style.bottom = screenPosition.y - _cursorElement.resolvedStyle.height;}
Each frame, the Software Cursor’s position will be updated (and synced with the Hardware Cursor, if it exists). The software cursor’s viewport position (from 0 to 1) will be calculated using the screen position (from 0 to the Screen Width and Height) and updated in the PlayerControlData struct. Finally, the viewport position will be passed to the UI Document, which will calculate the new position of the cursor VisualElement using the actual (resolvedStyle) width and height of its parent container.
In this example, the player has an inventory frame that follows the cursor around; when the “in-game” cursor is disabled to interact with non-diegetic menus, the inventory frame is hidden to indicate the difference to the player (and to keep the hardware cursor hidden at all times, which ensures a more consistent scaling/representation of the software cursor, since it uses the same scaling settings as the UI Document’s Panel asset)

<ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="False"> <Style src="project://database/Assets/Resources/UI/Elements/SoftwareCursor.uss"/> <ui:VisualElement name="cursor-container" picking-mode="Ignore"> <ui:Image name="cursor-image" usage-hints="DynamicTransform" picking-mode="Ignore"/> <ui:VisualElement name="inventory" usage-hints="GroupTransform" picking-mode="Ignore"/> </ui:VisualElement></ui:UXML>
#cursor-container { flex-grow: 1;}#cursor-element { position: absolute; align-items: flex-start; align-content: flex-start; justify-content: flex-start; flex-direction: row;}#cursor-image { position: absolute; background-image: url("/Assets/Art/Textures/software-cursor.png"); width: 36px; height: 36px;}#inventory { position: absolute; flex-shrink: 1; margin-top: 4px; margin-left: 4px;}
However, this implementation only gets us so far. We now have an authoritative source to read our game cursor’s position and delta from each frame; but it still does not interact with our game world at all! From here, you’ll need to use good old Camera.ViewportToWorldPoint() in order to get the Cursor’s World position and handle collision detection in whatever way makes sense for your project — physics raycasts, moving a dummy object with a Unity Collider, etc. (I’m making use of the Collision Layer/World features in the Latios Framework to define what makes an entity impactable by the mouse — perhaps something to cover next time around!)
Questions about how to get this running in your own project? Let me know below!

Leave a Reply