diff --git a/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java b/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java
index ebeb5f8e434..de9aeabbd2f 100644
--- a/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java
+++ b/vaadin-details-flow-parent/vaadin-details-flow/src/main/java/com/vaadin/flow/component/details/Details.java
@@ -35,6 +35,7 @@
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.component.shared.HasTooltip;
import com.vaadin.flow.component.shared.SlotUtils;
+import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.function.SerializableFunction;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.signals.Signal;
@@ -441,6 +442,28 @@ public void setOpened(boolean opened) {
doSetOpened(opened);
}
+ /**
+ * Binds the opened state to the given signal. The binding is two-way:
+ * signal changes push to the DOM property, and client-side property changes
+ * invoke the write callback.
+ *
+ * While a signal is bound, any attempt to set the opened state manually
+ * throws {@link com.vaadin.flow.signals.BindingActiveException}.
+ *
+ * @param signal
+ * the signal to bind, not {@code null}
+ * @param writeCallback
+ * the callback to propagate value changes back, or {@code null}
+ * for a read-only binding
+ * @since 25.1
+ */
+ public void bindOpened(Signal signal,
+ SerializableConsumer writeCallback) {
+ Objects.requireNonNull(signal, "Signal cannot be null");
+ getElement().bindProperty("opened",
+ signal.map(v -> v == null ? Boolean.FALSE : v), writeCallback);
+ }
+
public static class OpenedChangeEvent extends ComponentEvent {
private final boolean opened;
diff --git a/vaadin-details-flow-parent/vaadin-details-flow/src/test/java/com/vaadin/flow/component/details/DetailsOpenedSignalTest.java b/vaadin-details-flow-parent/vaadin-details-flow/src/test/java/com/vaadin/flow/component/details/DetailsOpenedSignalTest.java
new file mode 100644
index 00000000000..4479076b948
--- /dev/null
+++ b/vaadin-details-flow-parent/vaadin-details-flow/src/test/java/com/vaadin/flow/component/details/DetailsOpenedSignalTest.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.details;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.vaadin.flow.component.UI;
+import com.vaadin.flow.signals.BindingActiveException;
+import com.vaadin.flow.signals.local.ValueSignal;
+import com.vaadin.tests.AbstractSignalsUnitTest;
+
+public class DetailsOpenedSignalTest extends AbstractSignalsUnitTest {
+
+ private Details details;
+ private ValueSignal signal;
+
+ @Before
+ public void setup() {
+ details = new Details();
+ signal = new ValueSignal<>(false);
+ }
+
+ @After
+ public void tearDown() {
+ if (details != null && details.isAttached()) {
+ details.removeFromParent();
+ }
+ }
+
+ @Test
+ public void bindOpened_signalBound_propertySync() {
+ details.bindOpened(signal, signal::set);
+ UI.getCurrent().add(details);
+
+ Assert.assertFalse(details.isOpened());
+
+ signal.set(true);
+ Assert.assertTrue(details.isOpened());
+
+ signal.set(false);
+ Assert.assertFalse(details.isOpened());
+ }
+
+ @Test
+ public void bindOpened_notAttached_noEffect() {
+ details.bindOpened(signal, signal::set);
+
+ boolean initial = details.isOpened();
+ signal.set(true);
+ Assert.assertEquals(initial, details.isOpened());
+ }
+
+ @Test
+ public void bindOpened_detachAndReattach() {
+ details.bindOpened(signal, signal::set);
+ UI.getCurrent().add(details);
+
+ signal.set(true);
+ Assert.assertTrue(details.isOpened());
+
+ details.removeFromParent();
+ signal.set(false);
+ Assert.assertTrue(details.isOpened());
+
+ UI.getCurrent().add(details);
+ Assert.assertFalse(details.isOpened());
+ }
+
+ @Test(expected = BindingActiveException.class)
+ public void bindOpened_setWhileBound_throws() {
+ details.bindOpened(signal, signal::set);
+ UI.getCurrent().add(details);
+
+ details.setOpened(true);
+ }
+
+ @Test(expected = BindingActiveException.class)
+ public void bindOpened_doubleBind_throws() {
+ details.bindOpened(signal, signal::set);
+ var other = new ValueSignal<>(true);
+ details.bindOpened(other, other::set);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void bindOpened_nullSignal_throwsNPE() {
+ details.bindOpened(null, null);
+ }
+}