diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -475,7 +475,8 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
             "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
             "opacity" => {
                 let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
-                let o = if o > 1.0 { o / 100.0 } else { o };
+                let percent = v.as_str().is_some_and(|s| s.trim().ends_with('%'));
+                let o = if percent || o > 1.0 { o / 100.0 } else { o };
                 doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
             }
             "blend" | "blendMode" => {
diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs
--- a/crates/engine/src/panels_behaviour_tests.rs
+++ b/crates/engine/src/panels_behaviour_tests.rs
@@ -475,3 +475,14 @@ fn page_navigation_skips_dividers() {
     ex(&mut s, "view.nextPage", json!({}));
     assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
 }
+
+#[test]
+fn opacity_percent_strings_always_divide_by_100() {
+    let mut s = Session::new();
+    let r = rect(&mut s, "R", 0.0, 0.0, None);
+    for (v, want) in [(json!("1%"), 0.01), (json!("0.5%"), 0.005), (json!("50%"), 0.5), (json!(1), 1.0), (json!(50), 0.5), (json!(0.25), 0.25)] {
+        ex(&mut s, "node.set", json!({"id": r, "props": {"opacity": v.clone()}}));
+        let o = s.doc().unwrap().node(r).unwrap().opacity;
+        assert!((o - want).abs() < 1e-6, "opacity {v} gave {o}, want {want}");
+    }
+}
