diff --git a/Documentation/leds/uledtriggers.rst b/Documentation/leds/uledtriggers.rst
new file mode 100644
index 000000000000..6ec5cbf8f13e
--- /dev/null
+++ b/Documentation/leds/uledtriggers.rst
@@ -0,0 +1,36 @@
+======================
+Userspace LED Triggers
+======================
+
+The uledtriggers driver supports userspace LED triggers. This can be useful
+to create a more flexible architecture for applications to control LEDs.
+
+
+Usage
+=====
+
+When the driver is loaded, a character device is created at /dev/uledtriggers.
+To create a new LED trigger, open /dev/uledtriggers and write a
+uledtriggers_user_dev structure to it (found in kernel public header file
+linux/uledtriggers.h)::
+
+    #define LED_TRIGGER_MAX_NAME_SIZE 50
+
+    struct uledtriggers_user_dev {
+	char name[LED_TRIGGER_MAX_NAME_SIZE];
+    };
+
+A new LED trigger will be created with the name given. The name can consist of
+alphanumeric, hyphen and underscore characters.
+
+After the initial setup, writing an int value will set the trigger's
+brightness, equivalent to calling led_trigger_event().
+
+Alternatively, there are ioctls (defined in the public header file) for setup,
+changing trigger brightness, or doing blinking.
+
+The LED trigger will be removed when the open file handle to /dev/uledtriggers
+is closed.
+
+Multiple LED triggers are created by opening additional file handles to
+/dev/uledtriggers.
diff --git a/drivers/leds/Kconfig b/drivers/leds/Kconfig
index 2b27d043921c..7cd2fbcb1aa5 100644
--- a/drivers/leds/Kconfig
+++ b/drivers/leds/Kconfig
@@ -921,6 +921,15 @@ config LEDS_USER
 	  support in kernel. To compile this driver as a module, choose 'm' here:
 	  the module will be called uleds.
 
+config LED_TRIGGERS_USER
+	tristate "Userspace LED triggers support"
+	depends on LEDS_CLASS
+	select LEDS_TRIGGERS
+	help
+	  This option enables support for userspace LED triggers. Say 'y' to enable
+	  this support in kernel. To compile this driver as a module, choose 'm'
+	  here: the module will be called uledtriggers.
+
 config LEDS_NIC78BX
 	tristate "LED support for NI PXI NIC78bx devices"
 	depends on LEDS_CLASS
diff --git a/drivers/leds/Makefile b/drivers/leds/Makefile
index 6ad52e219ec6..c71569a59b15 100644
--- a/drivers/leds/Makefile
+++ b/drivers/leds/Makefile
@@ -108,6 +108,7 @@ obj-$(CONFIG_LEDS_SPI_BYTE)		+= leds-spi-byte.o
 
 # LED Userspace Drivers
 obj-$(CONFIG_LEDS_USER)			+= uleds.o
+obj-$(CONFIG_LED_TRIGGERS_USER)			+= uledtriggers.o
 
 # Flash and Torch LED Drivers
 obj-$(CONFIG_LEDS_CLASS_FLASH)		+= flash/
diff --git a/drivers/leds/uledtriggers.c b/drivers/leds/uledtriggers.c
new file mode 100644
index 000000000000..4fdb8cf4c82d
--- /dev/null
+++ b/drivers/leds/uledtriggers.c
@@ -0,0 +1,384 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Userspace LED triggers driver
+ *
+ * Copyright (C) 2025 Craig McQueen <craig@mcqueen.au>
+ */
+#include <linux/ctype.h>
+#include <linux/fs.h>
+#include <linux/init.h>
+#include <linux/leds.h>
+#include <linux/miscdevice.h>
+#include <linux/module.h>
+
+#include <uapi/linux/uledtriggers.h>
+
+#define ULEDTRIGGERS_NAME	"uledtriggers"
+
+enum uledtriggers_state {
+	ULEDTRIGGERS_STATE_UNKNOWN,
+	ULEDTRIGGERS_STATE_REGISTERED,
+};
+
+enum uledtriggers_trig_state {
+	TRIG_STATE_EVENT,
+	TRIG_STATE_BLINK,
+};
+
+struct uledtriggers_device {
+	struct uledtriggers_user_dev	user_dev;
+	struct led_trigger	led_trigger;
+	struct mutex		mutex;
+	enum uledtriggers_state	state;
+	enum uledtriggers_trig_state	trig_state;
+	int			brightness;
+	unsigned long		trig_delay_on;
+	unsigned long		trig_delay_off;
+};
+
+static struct miscdevice uledtriggers_misc;
+
+static int set_led_trigger(struct uledtriggers_device *udev)
+{
+	int retval = 0;
+	enum uledtriggers_trig_state trig_state;
+
+	retval = mutex_lock_interruptible(&udev->mutex);
+	if (retval)
+		return retval;
+
+	trig_state = udev->trig_state;
+	switch (trig_state) {
+	default:
+	case TRIG_STATE_EVENT:
+		led_trigger_event(&udev->led_trigger, udev->brightness);
+		break;
+	case TRIG_STATE_BLINK:
+		led_trigger_blink(&udev->led_trigger, udev->trig_delay_on, udev->trig_delay_off);
+		break;
+	}
+	mutex_unlock(&udev->mutex);
+
+	return retval;
+}
+
+/*
+ * When an LED is connected to the trigger, this 'activate' function runs and
+ * sets the initial state of the LED.
+ */
+static int uledtriggers_trig_activate(struct led_classdev *led_cdev)
+{
+	struct led_trigger		*trig;
+	struct uledtriggers_device	*udev;
+
+	trig = led_cdev->trigger;
+	udev = container_of(trig, struct uledtriggers_device, led_trigger);
+	return set_led_trigger(udev);
+}
+
+static int uledtriggers_open(struct inode *inode, struct file *file)
+{
+	struct uledtriggers_device *udev;
+
+	udev = kzalloc(sizeof(*udev), GFP_KERNEL);
+	if (!udev)
+		return -ENOMEM;
+
+	mutex_init(&udev->mutex);
+	udev->state = ULEDTRIGGERS_STATE_UNKNOWN;
+
+	file->private_data = udev;
+	stream_open(inode, file);
+
+	return 0;
+}
+
+/*
+ * Name validation: Allow only alphanumeric, hyphen or underscore.
+ */
+static bool is_trigger_name_valid(const char * name)
+{
+	size_t i;
+
+	if (name[0] == '\0')
+		return false;
+
+	for (i = 0; i < TRIG_NAME_MAX; i++) {
+		if (name[i] == '\0')
+			break;
+		if (!isalnum(name[i]) && name[i] != '-' && name[i] != '_')
+			return false;
+	}
+	/* Length check. */
+	return (i < TRIG_NAME_MAX);
+}
+
+static int dev_setup(struct uledtriggers_device *udev, const char __user *buffer)
+{
+	const char *name;
+	int retval;
+
+	retval = mutex_lock_interruptible(&udev->mutex);
+	if (retval)
+		return retval;
+
+	if (udev->state == ULEDTRIGGERS_STATE_REGISTERED) {
+		retval = -EBUSY;
+		goto out;
+	}
+
+	if (copy_from_user(&udev->user_dev, buffer,
+			   sizeof(struct uledtriggers_user_dev))) {
+		retval = -EFAULT;
+		goto out;
+	}
+
+	name = udev->user_dev.name;
+	if (!is_trigger_name_valid(name)) {
+		retval = -EINVAL;
+		goto out;
+	}
+
+	udev->led_trigger.name = udev->user_dev.name;
+	udev->led_trigger.activate = uledtriggers_trig_activate;
+	retval = led_trigger_register(&udev->led_trigger);
+	if (retval < 0) {
+		udev->led_trigger.name = NULL;
+		goto out;
+	}
+
+	udev->state = ULEDTRIGGERS_STATE_REGISTERED;
+
+out:
+	mutex_unlock(&udev->mutex);
+
+	return retval;
+}
+
+static int write_brightness(struct uledtriggers_device *udev, const char __user *buffer)
+{
+	int retval;
+	int brightness;
+
+	retval = mutex_lock_interruptible(&udev->mutex);
+	if (retval)
+		return retval;
+
+	if (udev->state != ULEDTRIGGERS_STATE_REGISTERED) {
+		retval = -EBUSY;
+		goto out;
+	}
+
+	if (copy_from_user(&brightness, buffer,
+			   sizeof(brightness))) {
+		retval = -EFAULT;
+		goto out;
+	}
+
+	udev->trig_delay_on = 0u;
+	udev->trig_delay_off = 0u;
+	udev->brightness = brightness;
+	udev->trig_state = TRIG_STATE_EVENT;
+	led_trigger_event(&udev->led_trigger, brightness);
+
+out:
+	mutex_unlock(&udev->mutex);
+
+	return retval;
+}
+
+static ssize_t uledtriggers_write(struct file *file, const char __user *buffer,
+	size_t count, loff_t *ppos)
+{
+	struct uledtriggers_device *udev = file->private_data;
+	int retval;
+
+	if (count == 0)
+		return 0;
+
+	switch (udev->state) {
+	case ULEDTRIGGERS_STATE_UNKNOWN:
+		if (count != sizeof(struct uledtriggers_user_dev)) {
+			return -EINVAL;
+		}
+		retval = dev_setup(udev, buffer);
+		if (retval < 0)
+			return retval;
+		return count;
+	case ULEDTRIGGERS_STATE_REGISTERED:
+		if (count != sizeof(int)) {
+			return -EINVAL;
+		}
+		retval = write_brightness(udev, buffer);
+		if (retval < 0)
+			return retval;
+		return count;
+	default:
+		return -EBADFD;
+	}
+}
+
+static long uledtriggers_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
+{
+	struct uledtriggers_device *udev = file->private_data;
+	struct uledtriggers_blink blink;
+	struct uledtriggers_blink_oneshot blink_oneshot;
+	int brightness;
+	int retval = 0;
+
+	/*
+	 * the direction is a bitmask, and VERIFY_WRITE catches R/W
+	 * transfers. `Direction' is user-oriented, while
+	 * access_ok is kernel-oriented, so the concept of "read" and
+	 * "write" is reversed
+	 */
+	retval = 0;
+	if (_IOC_DIR(cmd) & _IOC_READ)
+		retval = !access_ok((void __user *)arg, _IOC_SIZE(cmd));
+	else if (_IOC_DIR(cmd) & _IOC_WRITE)
+		retval = !access_ok((void __user *)arg, _IOC_SIZE(cmd));
+	if (retval)
+		return -EFAULT;
+
+	switch (cmd) {
+	case ULEDTRIGGERS_IOC_DEV_SETUP:
+		retval = dev_setup(udev, (const char __user *)arg);
+		break;
+
+	case ULEDTRIGGERS_IOC_OFF:
+		retval = mutex_lock_interruptible(&udev->mutex);
+		if (retval)
+			return retval;
+		if (udev->state != ULEDTRIGGERS_STATE_REGISTERED) {
+			mutex_unlock(&udev->mutex);
+			return -EINVAL;
+		}
+		udev->trig_delay_on = 0u;
+		udev->trig_delay_off = 0u;
+		udev->brightness = 0;
+		udev->trig_state = TRIG_STATE_EVENT;
+		led_trigger_event(&udev->led_trigger, LED_OFF);
+		mutex_unlock(&udev->mutex);
+		break;
+
+	case ULEDTRIGGERS_IOC_ON:
+		retval = mutex_lock_interruptible(&udev->mutex);
+		if (retval)
+			return retval;
+		if (udev->state != ULEDTRIGGERS_STATE_REGISTERED) {
+			mutex_unlock(&udev->mutex);
+			return -EINVAL;
+		}
+		udev->trig_delay_on = 0u;
+		udev->trig_delay_off = 0u;
+		udev->brightness = LED_FULL;
+		udev->trig_state = TRIG_STATE_EVENT;
+		led_trigger_event(&udev->led_trigger, LED_FULL);
+		mutex_unlock(&udev->mutex);
+		break;
+
+	case ULEDTRIGGERS_IOC_EVENT:
+		retval = copy_from_user(&brightness,
+			(int __user *)arg,
+			sizeof(brightness));
+		if (retval)
+			return retval;
+		retval = mutex_lock_interruptible(&udev->mutex);
+		if (retval)
+			return retval;
+		if (udev->state != ULEDTRIGGERS_STATE_REGISTERED) {
+			mutex_unlock(&udev->mutex);
+			return -EINVAL;
+		}
+		udev->trig_delay_on = 0u;
+		udev->trig_delay_off = 0u;
+		udev->brightness = brightness;
+		udev->trig_state = TRIG_STATE_EVENT;
+		led_trigger_event(&udev->led_trigger, brightness);
+		mutex_unlock(&udev->mutex);
+		break;
+
+	case ULEDTRIGGERS_IOC_BLINK:
+		retval = copy_from_user(&blink,
+			(struct uledtriggers_blink __user *)arg,
+			sizeof(blink));
+		if (retval)
+			return retval;
+		retval = mutex_lock_interruptible(&udev->mutex);
+		if (retval)
+			return retval;
+		if (udev->state != ULEDTRIGGERS_STATE_REGISTERED) {
+			mutex_unlock(&udev->mutex);
+			return -EINVAL;
+		}
+		udev->trig_delay_on = blink.delay_on;
+		udev->trig_delay_off = blink.delay_off;
+		udev->brightness = LED_FULL;
+		udev->trig_state = TRIG_STATE_BLINK;
+		led_trigger_blink(&udev->led_trigger, blink.delay_on, blink.delay_off);
+		mutex_unlock(&udev->mutex);
+		break;
+
+	case ULEDTRIGGERS_IOC_BLINK_ONESHOT:
+		retval = copy_from_user(&blink_oneshot,
+			(struct uledtriggers_blink_oneshot __user *)arg,
+			sizeof(blink_oneshot));
+		if (retval)
+			return retval;
+		if (blink_oneshot.__unused)
+			return -EINVAL;
+		retval = mutex_lock_interruptible(&udev->mutex);
+		if (retval)
+			return retval;
+		if (udev->state != ULEDTRIGGERS_STATE_REGISTERED) {
+			mutex_unlock(&udev->mutex);
+			return -EINVAL;
+		}
+		udev->trig_delay_on = 0u;
+		udev->trig_delay_off = 0u;
+		udev->brightness = blink_oneshot.invert ? LED_FULL : LED_OFF;
+		udev->trig_state = TRIG_STATE_EVENT;
+		led_trigger_blink_oneshot(&udev->led_trigger, blink_oneshot.delay_on, blink_oneshot.delay_off, blink_oneshot.invert);
+		mutex_unlock(&udev->mutex);
+		break;
+
+	default:
+		retval = -ENOIOCTLCMD;
+		break;
+	}
+
+	return retval;
+}
+
+static int uledtriggers_release(struct inode *inode, struct file *file)
+{
+	struct uledtriggers_device *udev = file->private_data;
+
+	if (udev->state == ULEDTRIGGERS_STATE_REGISTERED) {
+		udev->state = ULEDTRIGGERS_STATE_UNKNOWN;
+		led_trigger_unregister(&udev->led_trigger);
+	}
+	kfree(udev);
+
+	return 0;
+}
+
+static const struct file_operations uledtriggers_fops = {
+	.owner		= THIS_MODULE,
+	.open		= uledtriggers_open,
+	.release	= uledtriggers_release,
+	.write		= uledtriggers_write,
+	.unlocked_ioctl	= uledtriggers_ioctl,
+};
+
+static struct miscdevice uledtriggers_misc = {
+	.fops		= &uledtriggers_fops,
+	.minor		= MISC_DYNAMIC_MINOR,
+	.name		= ULEDTRIGGERS_NAME,
+};
+
+module_misc_device(uledtriggers_misc);
+
+MODULE_AUTHOR("Craig McQueen <craig@mcqueen.au>");
+MODULE_DESCRIPTION("Userspace LED triggers driver");
+MODULE_LICENSE("GPL");
diff --git a/include/uapi/linux/uledtriggers.h b/include/uapi/linux/uledtriggers.h
new file mode 100644
index 000000000000..251fa0a31861
--- /dev/null
+++ b/include/uapi/linux/uledtriggers.h
@@ -0,0 +1,123 @@
+/* SPDX-License-Identifier: GPL-2.0+ WITH Linux-syscall-note */
+/*
+ * Userspace LED triggers driver support
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ */
+#ifndef _UAPI__ULEDTRIGGERS_H_
+#define _UAPI__ULEDTRIGGERS_H_
+
+/* See TRIG_NAME_MAX in linux/leds.h */
+#define LED_TRIGGER_MAX_NAME_SIZE	50
+
+/*
+ * Struct for initial write to setup, or ioctl ULEDTREGGERS_IOC_DEV_SETUP.
+ */
+struct uledtriggers_user_dev {
+	char name[LED_TRIGGER_MAX_NAME_SIZE];
+};
+
+/*
+ * Brightness levels for writes of int values, or for use with ULEDTRIGGERS_IOC_EVENT.
+ * These correspond to Linux kernel internal enum led_brightness in linux/leds.h.
+ */
+enum uledtriggers_brightness {
+	ULEDTRIGGERS_OFF		= 0,
+	ULEDTRIGGERS_ON			= 1,
+	ULEDTRIGGERS_HALF		= 127,
+	ULEDTRIGGERS_FULL		= 255,
+};
+
+/*
+ * Struct for ioctl ULEDTRIGGERS_IOC_BLINK.
+ */
+struct uledtriggers_blink {
+	unsigned long delay_on;
+	unsigned long delay_off;
+};
+
+/*
+ * Struct for ioctl ULEDTRIGGERS_IOC_BLINK_ONESHOT.
+ * Note padding at the end due to alignment (for 64-bit kernels). Ensure it's set to 0.
+ */
+struct uledtriggers_blink_oneshot {
+	unsigned long delay_on;
+	unsigned long delay_off;
+	int invert;
+	int __unused;
+};
+
+
+/* ioctl commands */
+
+#define ULEDTRIGGERS_IOC_MAGIC			't'
+
+/*
+ * Initial setup.
+ * E.g.:
+ *	int retval;
+ *	struct uledtriggers_user_dev dev_setup = { "transmogrifier" };
+ *	retval = ioctl(fd, ULEDTRIGGERS_IOC_DEV_SETUP, &dev_setup);
+ */
+#define ULEDTRIGGERS_IOC_DEV_SETUP	_IOW(ULEDTRIGGERS_IOC_MAGIC, 0x01, struct uledtriggers_user_dev)
+
+/*
+ * Turn the trigger off.
+ * E.g.:
+ *	int retval;
+ *	retval = ioctl(fd, ULEDTRIGGERS_IOC_OFF);
+ */
+#define ULEDTRIGGERS_IOC_OFF		_IO(ULEDTRIGGERS_IOC_MAGIC, 0x10)
+
+/*
+ * Turn the trigger on.
+ * E.g.:
+ *	int retval;
+ *	retval = ioctl(fd, ULEDTRIGGERS_IOC_ON);
+ */
+#define ULEDTRIGGERS_IOC_ON		_IO(ULEDTRIGGERS_IOC_MAGIC, 0x11)
+
+/*
+ * Set the LED trigger to a specified brightness.
+ * Refer to enum uledtriggers_brightness.
+ * E.g.:
+ *	int retval;
+ *	int brightness = ULEDTRIGGERS_FULL;
+ *	retval = ioctl(fd, ULEDTRIGGERS_IOC_EVENT, &brightness);
+ */
+#define ULEDTRIGGERS_IOC_EVENT		_IOW(ULEDTRIGGERS_IOC_MAGIC, 0x12, int)
+
+/*
+ * Set the LED trigger to blink continuously.
+ * E.g.:
+ *	int retval;
+ *	struct uledtriggers_blink blink;
+ *      blink.delay_on = 100;
+ *      blink.delay_off = 400;
+ *	retval = ioctl(fd, ULEDTRIGGERS_IOC_BLINK, &blink);
+ */
+#define ULEDTRIGGERS_IOC_BLINK		_IOW(ULEDTRIGGERS_IOC_MAGIC, 0x20, struct uledtriggers_blink)
+
+/*
+ * Set the LED trigger to blink once.
+ * E.g.:
+ *	int retval;
+ *	struct uledtriggers_blink_oneshot blink_oneshot;
+ *      blink_oneshot.delay_on = 100;
+ *      blink_oneshot.delay_off = 400;
+ *      blink_oneshot.invert = false;
+ *      blink_oneshot.__unused = 0;
+ *	retval = ioctl(fd, ULEDTRIGGERS_IOC_BLINK_ONESHOT, &blink_oneshot);
+ */
+#define ULEDTRIGGERS_IOC_BLINK_ONESHOT	_IOW(ULEDTRIGGERS_IOC_MAGIC, 0x21, struct uledtriggers_blink_oneshot)
+
+
+#endif /* _UAPI__ULEDTRIGGERS_H_ */
