diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5c8fb483..a4919952 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -55,9 +55,10 @@ boption(HYPRLAND "  Hyprland" ON REQUIRES WAYLAND)
 boption(HYPRLAND_IPC "    Hyprland IPC" ON REQUIRES HYPRLAND)
 boption(HYPRLAND_GLOBAL_SHORTCUTS "    Hyprland Global Shortcuts" ON REQUIRES HYPRLAND)
 boption(HYPRLAND_FOCUS_GRAB "    Hyprland Focus Grabbing" ON REQUIRES HYPRLAND)
-boption(I3 "  I3/Sway" ON)
-boption(I3_IPC "    I3/Sway IPC" ON REQUIRES I3)
+boption(HYPRLAND_SURFACE_EXTENSIONS "    Hyprland Surface Extensions" ON REQUIRES HYPRLAND)
 boption(X11 "X11" ON)
+boption(I3 "I3/Sway" ON)
+boption(I3_IPC "  I3/Sway IPC" ON REQUIRES I3)
 boption(SERVICE_STATUS_NOTIFIER "System Tray" ON)
 boption(SERVICE_PIPEWIRE "PipeWire" ON)
 boption(SERVICE_MPRIS "Mpris" ON)
diff --git a/src/wayland/CMakeLists.txt b/src/wayland/CMakeLists.txt
index 8005a833..db03cf16 100644
--- a/src/wayland/CMakeLists.txt
+++ b/src/wayland/CMakeLists.txt
@@ -77,6 +77,7 @@ qt_add_library(quickshell-wayland STATIC
 	platformmenu.cpp
 	popupanchor.cpp
 	xdgshell.cpp
+	util.cpp
 )
 
 # required to make sure the constructor is linked
diff --git a/src/wayland/hyprland/CMakeLists.txt b/src/wayland/hyprland/CMakeLists.txt
index cb375358..570cbe54 100644
--- a/src/wayland/hyprland/CMakeLists.txt
+++ b/src/wayland/hyprland/CMakeLists.txt
@@ -19,6 +19,11 @@ if (HYPRLAND_GLOBAL_SHORTCUTS)
 	list(APPEND HYPRLAND_MODULES Quickshell.Hyprland._GlobalShortcuts)
 endif()
 
+if (HYPRLAND_SURFACE_EXTENSIONS)
+	add_subdirectory(surface)
+	list(APPEND HYPRLAND_MODULES Quickshell.Hyprland._SurfaceExtensions)
+endif()
+
 qt_add_qml_module(quickshell-hyprland
 	URI Quickshell.Hyprland
 	VERSION 0.1
diff --git a/src/wayland/hyprland/module.md b/src/wayland/hyprland/module.md
index 6c2de249..0bdb6a7f 100644
--- a/src/wayland/hyprland/module.md
+++ b/src/wayland/hyprland/module.md
@@ -7,5 +7,6 @@ headers = [
 	"ipc/qml.hpp",
 	"focus_grab/qml.hpp",
 	"global_shortcuts/qml.hpp",
+	"surface/qml.hpp",
 ]
 -----
diff --git a/src/wayland/hyprland/surface/CMakeLists.txt b/src/wayland/hyprland/surface/CMakeLists.txt
new file mode 100644
index 00000000..04fa5c58
--- /dev/null
+++ b/src/wayland/hyprland/surface/CMakeLists.txt
@@ -0,0 +1,26 @@
+qt_add_library(quickshell-hyprland-surface-extensions STATIC
+	qml.cpp
+	manager.cpp
+	surface.cpp
+)
+
+qt_add_qml_module(quickshell-hyprland-surface-extensions
+	URI Quickshell.Hyprland._SurfaceExtensions
+	VERSION 0.1
+	DEPENDENCIES QtQml
+)
+
+install_qml_module(quickshell-hyprland-surface-extensions)
+
+wl_proto(quickshell-hyprland-surface-extensions
+	hyprland-surface-v1
+	"${CMAKE_CURRENT_SOURCE_DIR}/hyprland-surface-v1.xml"
+)
+
+target_link_libraries(quickshell-hyprland-surface-extensions PRIVATE
+	Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client
+)
+
+qs_module_pch(quickshell-hyprland-surface-extensions)
+
+target_link_libraries(quickshell PRIVATE quickshell-hyprland-surface-extensionsplugin)
diff --git a/src/wayland/hyprland/surface/hyprland-surface-v1.xml b/src/wayland/hyprland/surface/hyprland-surface-v1.xml
new file mode 100644
index 00000000..2f683365
--- /dev/null
+++ b/src/wayland/hyprland/surface/hyprland-surface-v1.xml
@@ -0,0 +1,100 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="hyprland_surface_v1">
+  <copyright>
+    Copyright © 2025 outfoxxed
+    All rights reserved.
+
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+
+    1. Redistributions of source code must retain the above copyright notice, this
+       list of conditions and the following disclaimer.
+
+    2. Redistributions in binary form must reproduce the above copyright notice,
+       this list of conditions and the following disclaimer in the documentation
+       and/or other materials provided with the distribution.
+
+    3. Neither the name of the copyright holder nor the names of its
+       contributors may be used to endorse or promote products derived from
+       this software without specific prior written permission.
+
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+    DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+    FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+    DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+    SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+    OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+  </copyright>
+
+  <description summary="hyprland-specific wl_surface extensions">
+    This protocol exposes hyprland-specific wl_surface properties.
+  </description>
+
+  <interface name="hyprland_surface_manager_v1" version="1">
+    <description summary="manager for hyprland surface objects">
+      This interface allows a client to create hyprland surface objects.
+    </description>
+
+    <request name="get_hyprland_surface">
+      <description summary="create a hyprland surface object">
+        Create a hyprland surface object for the given wayland surface.
+
+        If the wl_surface already has an associated hyprland_surface_v1 object,
+        even from a different manager, creation is a protocol error.
+      </description>
+
+      <arg name="id" type="new_id" interface="hyprland_surface_v1"/>
+      <arg name="surface" type="object" interface="wl_surface"/>
+    </request>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the hyprland surface manager">
+        Destroy the surface manager.
+        This does not destroy existing surface objects.
+      </description>
+    </request>
+
+    <enum name="error">
+      <entry name="already_constructed" value="0" summary="wl_surface already has a hyprland surface object"/>
+    </enum>
+  </interface>
+
+  <interface name="hyprland_surface_v1" version="1">
+    <description summary="hyprland-specific wl_surface properties">
+      This interface allows access to hyprland-specific properties of a wl_surface.
+
+      Once the wl_surface has been destroyed, the hyprland surface object must be
+      destroyed as well. All other operations are a protocol error.
+    </description>
+
+    <request name="set_opacity">
+      <description summary="set the overall opacity of the surface">
+        Sets a multiplier for the overall opacity of the surface.
+        This multiplier applies to visual effects such as blur behind the surface
+        in addition to the surface's content.
+
+        The default value is 1.0.
+        Setting a value outside of the range 0.0 - 1.0 (inclusive) is a protocol error.
+        Does not take effect until wl_surface.commit is called.
+      </description>
+
+      <arg name="opacity" type="fixed"/>
+    </request>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the hyprland surface interface">
+        Destroy the hyprland surface object, resetting properties provided
+        by this interface to their default values on the next wl_surface.commit.
+      </description>
+    </request>
+
+    <enum name="error">
+      <entry name="no_surface" value="0" summary="wl_surface was destroyed"/>
+      <entry name="out_of_range" value="1" summary="given opacity was not in the range 0.0 - 1.0 (inclusive)"/>
+    </enum>
+  </interface>
+</protocol>
diff --git a/src/wayland/hyprland/surface/manager.cpp b/src/wayland/hyprland/surface/manager.cpp
new file mode 100644
index 00000000..31829bb6
--- /dev/null
+++ b/src/wayland/hyprland/surface/manager.cpp
@@ -0,0 +1,24 @@
+#include "manager.hpp"
+
+#include <private/qwaylandwindow_p.h>
+#include <qwaylandclientextension.h>
+
+#include "surface.hpp"
+
+namespace qs::hyprland::surface::impl {
+
+HyprlandSurfaceManager::HyprlandSurfaceManager(): QWaylandClientExtensionTemplate(1) {
+	this->initialize();
+}
+
+HyprlandSurface*
+HyprlandSurfaceManager::createHyprlandExtension(QtWaylandClient::QWaylandWindow* surface) {
+	return new HyprlandSurface(this->get_hyprland_surface(surface->surface()));
+}
+
+HyprlandSurfaceManager* HyprlandSurfaceManager::instance() {
+	static auto* instance = new HyprlandSurfaceManager();
+	return instance;
+}
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/hyprland/surface/manager.hpp b/src/wayland/hyprland/surface/manager.hpp
new file mode 100644
index 00000000..8332241f
--- /dev/null
+++ b/src/wayland/hyprland/surface/manager.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <private/qwaylandwindow_p.h>
+#include <qwayland-hyprland-surface-v1.h>
+#include <qwaylandclientextension.h>
+
+#include "surface.hpp"
+
+namespace qs::hyprland::surface::impl {
+
+class HyprlandSurfaceManager
+    : public QWaylandClientExtensionTemplate<HyprlandSurfaceManager>
+    , public QtWayland::hyprland_surface_manager_v1 {
+public:
+	explicit HyprlandSurfaceManager();
+
+	HyprlandSurface* createHyprlandExtension(QtWaylandClient::QWaylandWindow* surface);
+
+	static HyprlandSurfaceManager* instance();
+};
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/hyprland/surface/qml.cpp b/src/wayland/hyprland/surface/qml.cpp
new file mode 100644
index 00000000..8477de55
--- /dev/null
+++ b/src/wayland/hyprland/surface/qml.cpp
@@ -0,0 +1,154 @@
+#include "qml.hpp"
+#include <memory>
+
+#include <private/qwaylandwindow_p.h>
+#include <qlogging.h>
+#include <qobject.h>
+#include <qqmlinfo.h>
+#include <qtmetamacros.h>
+#include <qtypes.h>
+#include <qwindow.h>
+
+#include "../../../window/proxywindow.hpp"
+#include "../../../window/windowinterface.hpp"
+#include "../../util.hpp"
+#include "manager.hpp"
+#include "surface.hpp"
+
+using QtWaylandClient::QWaylandWindow;
+
+namespace qs::hyprland::surface {
+
+HyprlandWindow* HyprlandWindow::qmlAttachedProperties(QObject* object) {
+	auto* proxyWindow = qobject_cast<ProxyWindowBase*>(object);
+
+	if (!proxyWindow) {
+		if (auto* iface = qobject_cast<WindowInterface*>(object)) {
+			proxyWindow = iface->proxyWindow();
+		}
+	}
+
+	qDebug() << "hlwindow for" << proxyWindow;
+	if (!proxyWindow) return nullptr;
+	return new HyprlandWindow(proxyWindow);
+}
+
+HyprlandWindow::HyprlandWindow(ProxyWindowBase* window): QObject(nullptr), proxyWindow(window) {
+	QObject::connect(
+	    window,
+	    &ProxyWindowBase::windowConnected,
+	    this,
+	    &HyprlandWindow::onWindowConnected
+	);
+
+	QObject::connect(window, &QObject::destroyed, this, &HyprlandWindow::onProxyWindowDestroyed);
+
+	if (window->backingWindow()) {
+		this->onWindowConnected();
+	}
+}
+
+qreal HyprlandWindow::opacity() const { return this->mOpacity; }
+
+void HyprlandWindow::setOpacity(qreal opacity) {
+	if (opacity == this->mOpacity) return;
+
+	if (opacity < 0.0 || opacity > 1.0) {
+		qmlWarning(this
+		) << "Cannot set HyprlandWindow.opacity to a value larger than 1.0 or smaller than 0.0";
+		return;
+	}
+
+	this->mOpacity = opacity;
+
+	if (this->surface) {
+		this->surface->setOpacity(opacity);
+		qs::wayland::util::scheduleCommit(this->mWaylandWindow);
+	}
+
+	emit this->opacityChanged();
+}
+
+void HyprlandWindow::onWindowConnected() {
+	this->mWindow = this->proxyWindow->backingWindow();
+	// disconnected by destructor
+	QObject::connect(
+	    this->mWindow,
+	    &QWindow::visibleChanged,
+	    this,
+	    &HyprlandWindow::onWindowVisibleChanged
+	);
+
+	this->onWindowVisibleChanged();
+}
+
+void HyprlandWindow::onWindowVisibleChanged() {
+	if (this->mWindow->isVisible()) {
+		if (!this->mWindow->handle()) {
+			this->mWindow->create();
+		}
+
+		this->mWaylandWindow = dynamic_cast<QWaylandWindow*>(this->mWindow->handle());
+
+		if (this->mWaylandWindow) {
+			// disconnected by destructor
+
+			QObject::connect(
+			    this->mWaylandWindow,
+			    &QWaylandWindow::surfaceCreated,
+			    this,
+			    &HyprlandWindow::onWaylandSurfaceCreated
+			);
+
+			QObject::connect(
+			    this->mWaylandWindow,
+			    &QWaylandWindow::surfaceDestroyed,
+			    this,
+			    &HyprlandWindow::onWaylandSurfaceDestroyed
+			);
+
+			if (this->mWaylandWindow->surface()) {
+				this->onWaylandSurfaceCreated();
+			}
+		}
+	}
+}
+
+void HyprlandWindow::onWaylandSurfaceCreated() {
+	auto* manager = impl::HyprlandSurfaceManager::instance();
+
+	if (!manager->isActive()) {
+		qWarning() << "The active compositor does not support the hyprland_surface_v1 protocol. "
+		              "HyprlandWindow will not work.";
+		return;
+	}
+
+	auto* ext = manager->createHyprlandExtension(this->mWaylandWindow);
+	this->surface = std::unique_ptr<impl::HyprlandSurface>(ext);
+
+	if (this->mOpacity != 1.0) {
+		this->surface->setOpacity(this->mOpacity);
+		qs::wayland::util::scheduleCommit(this->mWaylandWindow);
+	}
+}
+
+void HyprlandWindow::onWaylandSurfaceDestroyed() {
+	this->surface = nullptr;
+
+	if (!this->proxyWindow) {
+		this->deleteLater();
+	}
+}
+
+void HyprlandWindow::onProxyWindowDestroyed() {
+	// Don't delete the HyprlandWindow, and therefore the impl::HyprlandSurface until the wl_surface is destroyed.
+	// Deleting it when the proxy window is deleted will cause a full opacity frame between the destruction of the
+	// hyprland_surface_v1 and wl_surface objects.
+
+	if (this->surface == nullptr) {
+		this->proxyWindow = nullptr;
+		this->deleteLater();
+	}
+}
+
+} // namespace qs::hyprland::surface
diff --git a/src/wayland/hyprland/surface/qml.hpp b/src/wayland/hyprland/surface/qml.hpp
new file mode 100644
index 00000000..ce32a967
--- /dev/null
+++ b/src/wayland/hyprland/surface/qml.hpp
@@ -0,0 +1,74 @@
+#pragma once
+
+#include <memory>
+
+#include <private/qwaylandwindow_p.h>
+#include <qobject.h>
+#include <qqmlintegration.h>
+#include <qtmetamacros.h>
+#include <qtypes.h>
+#include <qwindow.h>
+
+#include "../../../window/proxywindow.hpp"
+#include "surface.hpp"
+
+namespace qs::hyprland::surface {
+
+///! Hyprland specific QsWindow properties.
+/// Allows setting hyprland specific window properties on a @@Quickshell.QsWindow or subclass,
+/// as an attached object.
+///
+/// #### Example
+/// ```qml
+/// @@Quickshell.PopupWindow {
+///   // ...
+///   HyprlandWindow.opacity: 0.6 // any number or binding
+/// }
+/// ```
+///
+/// > [!NOTE] Requires at least hyprland 0.47.0, or [hyprland-surface-v1] support.
+///
+/// [hyprland-surface-v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-surface-v1.xml
+class HyprlandWindow: public QObject {
+	Q_OBJECT;
+	/// A multiplier for the window's overall opacity, ranging from 1.0 to 0.0. Overall opacity includes the opacity of
+	/// both the window content *and* visual effects such as blur that apply to it.
+	///
+	/// Default: 1.0
+	Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity NOTIFY opacityChanged);
+	QML_ELEMENT;
+	QML_UNCREATABLE("HyprlandWindow can only be used as an attached object.");
+	QML_ATTACHED(HyprlandWindow);
+
+public:
+	explicit HyprlandWindow(ProxyWindowBase* window);
+
+	[[nodiscard]] static bool available();
+
+	[[nodiscard]] qreal opacity() const;
+	void setOpacity(qreal opacity);
+
+	static HyprlandWindow* qmlAttachedProperties(QObject* object);
+
+signals:
+	void opacityChanged();
+
+private slots:
+	void onWindowConnected();
+	void onWindowVisibleChanged();
+	void onWaylandSurfaceCreated();
+	void onWaylandSurfaceDestroyed();
+	void onProxyWindowDestroyed();
+
+private:
+	void disconnectWaylandWindow();
+
+	ProxyWindowBase* proxyWindow = nullptr;
+	QWindow* mWindow = nullptr;
+	QtWaylandClient::QWaylandWindow* mWaylandWindow = nullptr;
+
+	qreal mOpacity = 1.0;
+	std::unique_ptr<impl::HyprlandSurface> surface;
+};
+
+} // namespace qs::hyprland::surface
diff --git a/src/wayland/hyprland/surface/surface.cpp b/src/wayland/hyprland/surface/surface.cpp
new file mode 100644
index 00000000..d1aa24fb
--- /dev/null
+++ b/src/wayland/hyprland/surface/surface.cpp
@@ -0,0 +1,19 @@
+#include "surface.hpp"
+
+#include <qtypes.h>
+#include <qwayland-hyprland-surface-v1.h>
+#include <wayland-hyprland-surface-v1-client-protocol.h>
+#include <wayland-util.h>
+
+namespace qs::hyprland::surface::impl {
+
+HyprlandSurface::HyprlandSurface(::hyprland_surface_v1* surface)
+    : QtWayland::hyprland_surface_v1(surface) {}
+
+HyprlandSurface::~HyprlandSurface() { this->destroy(); }
+
+void HyprlandSurface::setOpacity(qreal opacity) {
+	this->set_opacity(wl_fixed_from_double(opacity));
+}
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/hyprland/surface/surface.hpp b/src/wayland/hyprland/surface/surface.hpp
new file mode 100644
index 00000000..a27e50e3
--- /dev/null
+++ b/src/wayland/hyprland/surface/surface.hpp
@@ -0,0 +1,21 @@
+#pragma once
+
+#include <qobject.h>
+#include <qtclasshelpermacros.h>
+#include <qtmetamacros.h>
+#include <qtypes.h>
+#include <qwayland-hyprland-surface-v1.h>
+#include <wayland-hyprland-surface-v1-client-protocol.h>
+
+namespace qs::hyprland::surface::impl {
+
+class HyprlandSurface: public QtWayland::hyprland_surface_v1 {
+public:
+	explicit HyprlandSurface(::hyprland_surface_v1* surface);
+	~HyprlandSurface() override;
+	Q_DISABLE_COPY_MOVE(HyprlandSurface);
+
+	void setOpacity(qreal opacity);
+};
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/util.cpp b/src/wayland/util.cpp
new file mode 100644
index 00000000..6bce2621
--- /dev/null
+++ b/src/wayland/util.cpp
@@ -0,0 +1,17 @@
+#include "util.hpp"
+
+#include <private/qwaylandwindow_p.h>
+#include <qpa/qwindowsysteminterface.h>
+
+namespace qs::wayland::util {
+
+void scheduleCommit(QtWaylandClient::QWaylandWindow* window) {
+	// This seems to be one of the less offensive ways to force Qt to send a wl_surface.commit on its own terms.
+	// Ideally we would trigger the commit more directly.
+	QWindowSystemInterface::handleExposeEvent(
+	    window->window(),
+	    QRect(QPoint(), window->geometry().size())
+	);
+}
+
+} // namespace qs::wayland::util
diff --git a/src/wayland/util.hpp b/src/wayland/util.hpp
new file mode 100644
index 00000000..7967fadc
--- /dev/null
+++ b/src/wayland/util.hpp
@@ -0,0 +1,9 @@
+#pragma once
+
+#include <private/qwaylandwindow_p.h>
+
+namespace qs::wayland::util {
+
+void scheduleCommit(QtWaylandClient::QWaylandWindow* window);
+
+}