diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -4004,6 +4004,29 @@ fn preparing_a_form_through_tools() {
     assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
 }
 
+#[test]
+fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() {
+    // #4949: two buttons placed separately are two groups; naming one after the other makes
+    // them one group, and a clashing export value is renamed instead of shared.
+    let dir = workdir("radio-join");
+    let mut a = auto(&dir);
+    let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+    ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" }));
+    ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" }));
+    ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } }));
+    let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" }));
+    assert_eq!(r["field"], "size");
+    ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" }));
+    let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap();
+    let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+    let f = fields["fields"].as_array().unwrap();
+    assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["size"], "one group");
+    assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value");
+    assert_eq!(f[0]["value"], "S", "the group keeps its selection");
+    ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } }));
+    assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1");
+}
+
 #[test]
 fn rotating_a_field_through_tools() {
     let dir = workdir("rotate-field");
diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs
--- a/crates/forms/src/author.rs
+++ b/crates/forms/src/author.rs
@@ -11,6 +11,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
 use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs};
 
 mod edit;
+mod join;
 mod lifecycle;
 mod look;
 mod tree;
diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs
--- a/crates/forms/src/author/edit.rs
+++ b/crates/forms/src/author/edit.rs
@@ -1,3 +1,4 @@
+use super::join::{check_join, join_radio_group};
 use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets};
 use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
 use super::*;
@@ -71,6 +72,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
         crate::set_field_actions(doc, name, acts)?;
     }
     let mut new_name = name.to_string();
+    // A radio button renamed to another radio group's name joins that group (#4949). The other
+    // properties are written first, to this field, and its buttons then move.
+    let mut join = None;
     if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
         // A full name under the same parents only renames the field's own part (the editor
         // shows nested fields by their full name): it isn't moved, so it keeps its place and
@@ -79,7 +83,16 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
             (Some((parents, own)), Some((current, _))) if parents == current => own,
             _ => n,
         };
-        if n.contains('.') {
+        let full = match f.name.rsplit_once('.') {
+            Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"),
+            _ => n.to_string(),
+        };
+        let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full);
+        if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) {
+            check_join(doc, &f, group)?;
+            join = Some(group.clone());
+            new_name = full;
+        } else if n.contains('.') {
             // A full name: the field moves under the parent fields it names (#2696).
             let parts = name_parts(n)?;
             if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {
@@ -385,7 +398,13 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
     }
     // A label-only edit must not replace another widget's custom appearances.
     let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);
-    redraw_widgets(doc, &new_name, only)?;
+    if let Some(group) = &join {
+        redraw_widgets(doc, name, only)?;
+        join_radio_group(doc, &f, group)?;
+        redraw_field(doc, &new_name)?;
+    } else {
+        redraw_widgets(doc, &new_name, only)?;
+    }
     if props.calculate.is_some() {
         crate::recalculate(doc)?;
     }
diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs
new file mode 100644
--- /dev/null
+++ b/crates/forms/src/author/join.rs
@@ -0,0 +1,129 @@
+//! Renaming a radio button to an existing radio group's name makes it a button of that group
+//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2
+//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state.
+
+use std::collections::{HashMap, HashSet};
+
+use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers};
+use super::tree::{detach, list_of};
+use super::*;
+
+/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA`
+/// keeps only the widget's own triggers.
+const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"];
+
+/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2).
+fn has_opt(doc: &Document, field: ObjRef) -> bool {
+    doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt"))
+}
+
+/// Refuse what [`join_radio_group`] can't do, before anything is written.
+pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> {
+    for x in [f, target] {
+        if has_opt(doc, x.obj) {
+            return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name));
+        }
+        if x.widgets.is_empty() {
+            return invalid(format!("{} has no button", x.name));
+        }
+    }
+    if target.locked() {
+        return invalid(format!("{} is locked: unlock it first", target.name));
+    }
+    Ok(())
+}
+
+/// Move every button of radio field `f` into radio field `target`. Each keeps its export value
+/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a
+/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s
+/// selected value. `f` leaves the form; its own value and field-level properties go with it,
+/// and `target`'s stay. Call [`check_join`] first.
+pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> {
+    let theirs: HashSet<String> = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect();
+    let mut taken = theirs.clone();
+    taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone()));
+    // The group's value counts only when one of its buttons has it.
+    let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned();
+    // Each clashing value of `f` gets one fresh name, shared by every button that had it. Of
+    // `taken.len() + 1` names at least one is free, so the search always ends in one.
+    let mut renamed: HashMap<String, String> = HashMap::new();
+    for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) {
+        if renamed.contains_key(old) {
+            continue;
+        }
+        let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else {
+            return invalid(format!("no free export value for {old}"));
+        };
+        taken.insert(fresh.clone());
+        renamed.insert(old.to_owned(), fresh);
+    }
+    // A group that is its own one button gets a field with a separate widget first, so it can
+    // hold more than one.
+    if let [only] = target.widgets.as_slice()
+        && only.obj == target.obj
+    {
+        split_merged(doc, target, only)?;
+    }
+    detach(doc, f.obj)?;
+    // `f` is no longer a field, even when it stays on as a widget.
+    drop_from_calculation_order(doc, |r| r == f.obj)?;
+    let mut kids = Vec::with_capacity(f.widgets.len());
+    for w in &f.widgets {
+        let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
+        if w.obj == f.obj {
+            for k in FIELD_ONLY_KEYS {
+                d.remove(k);
+            }
+            let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
+            match aa.as_ref().map(split_triggers) {
+                Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)),
+                Some((None, _)) => {
+                    d.remove(b"AA");
+                }
+                None => {}
+            }
+        }
+        let mut state = w.on_state.clone();
+        if let Some(old) = w.on_state.as_deref()
+            && let Some(fresh) = renamed.get(old)
+            && rename_state(doc, &mut d, old, fresh)
+        {
+            state = Some(fresh.clone());
+        }
+        let on = state.is_some() && state == selected;
+        let shown = match (&state, on) {
+            (Some(s), true) => Object::Name(crate::name_bytes(s)),
+            _ => Object::name("Off"),
+        };
+        d.set(b"AS".to_vec(), shown);
+        d.set(b"Parent".to_vec(), Object::Ref(target.obj));
+        doc.set(w.obj, Object::Dict(d));
+        kids.push(Object::Ref(w.obj));
+    }
+    // `/Kids` may be an indirect array: read through it so the group's own buttons stay.
+    let mut list = list_of(doc, target.obj, b"Kids");
+    list.extend(kids);
+    doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?;
+    Ok(())
+}
+
+/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`,
+/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other
+/// users. Whether `/N` had the state: when it didn't, nothing is changed.
+fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool {
+    let (old, new) = (crate::name_bytes(old), crate::name_bytes(new));
+    let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false };
+    let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned());
+    if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) {
+        return false;
+    }
+    for key in [&b"N"[..], b"D", b"R"] {
+        let Some(mut sub) = states(&ap, key) else { continue };
+        if let Some(v) = sub.remove(&old) {
+            sub.set(new.clone(), v);
+            ap.set(key.to_vec(), Object::Dict(sub));
+        }
+    }
+    d.set(b"AP".to_vec(), Object::Dict(ap));
+    true
+}
diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs
--- a/crates/forms/src/author/lifecycle.rs
+++ b/crates/forms/src/author/lifecycle.rs
@@ -215,7 +215,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
 
 /// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or
 /// by reference.
-fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
+pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
     let Some(root) = doc.root() else { return Ok(()) };
     let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
     let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) };
@@ -237,7 +237,23 @@ fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool
 }
 
 /// Widget-only keys: they stay with each widget when a merged field is split.
-const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"];
+const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"];
+
+/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`,
+/// `F`, `V`, `C`, Table 199) belong to the field.
+const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"];
+
+/// Split additional-actions dictionary `aa` into the widget's triggers and the field's.
+/// An empty half is `None`.
+pub(super) fn split_triggers(aa: &Dict) -> (Option<Dict>, Option<Dict>) {
+    let (mut widget, mut field) = (Dict::new(), Dict::new());
+    for (k, v) in aa.iter() {
+        let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field };
+        to.set(k.clone(), v.clone());
+    }
+    let some = |d: Dict| (!d.is_empty()).then_some(d);
+    (some(widget), some(field))
+}
 
 /// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page
 /// gets a widget at the same place, belonging to the same field, so they share one value.
@@ -258,40 +274,7 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
         return Ok(0);
     }
     // A merged field/widget becomes a field with one widget kid.
-    let mut model_ref = model.obj;
-    if model.obj == f.obj {
-        let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
-        let mut kid = Dict::new();
-        for k in WIDGET_KEYS {
-            if let Some(v) = d.get(k) {
-                kid.set(k.to_vec(), v.clone());
-            }
-        }
-        kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
-        let kr = doc.add(Object::Dict(kid));
-        doc.update_dict(f.obj, |d| {
-            for k in WIDGET_KEYS {
-                d.remove(k);
-            }
-            d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
-        })?;
-        // The page lists the widget now, not the field.
-        if let Some(p) = model.page {
-            let pr = refs[p];
-            let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
-            let swap =
-                |a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
-            match annots {
-                Some(Object::Ref(ar)) => {
-                    let a = doc.get(ar).as_array().cloned().unwrap_or_default();
-                    doc.set(ar, Object::Array(swap(a)));
-                }
-                Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
-                _ => {}
-            }
-        }
-        model_ref = kr;
-    }
+    let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj };
     let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default();
     let mut added = Vec::new();
     for p in &targets {
@@ -324,3 +307,52 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
     redraw_field(doc, name)?;
     Ok(added.len())
 }
+
+/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that
+/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid.
+pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result<ObjRef, FormError> {
+    let refs = page_refs(doc);
+    let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
+    let mut kid = Dict::new();
+    for k in WIDGET_KEYS {
+        if let Some(v) = d.get(k) {
+            kid.set(k.to_vec(), v.clone());
+        }
+    }
+    // The widget's own triggers go with it; the field's stay.
+    let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
+    let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default();
+    if let Some(w) = widget_aa {
+        kid.set(b"AA".to_vec(), Object::Dict(w));
+    }
+    kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
+    let kr = doc.add(Object::Dict(kid));
+    doc.update_dict(f.obj, |d| {
+        for k in WIDGET_KEYS {
+            d.remove(k);
+        }
+        if aa.is_some() {
+            match field_aa {
+                Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)),
+                None => {
+                    d.remove(b"AA");
+                }
+            }
+        }
+        d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
+    })?;
+    // The page lists the widget now, not the field.
+    if let Some(&pr) = model.page.and_then(|p| refs.get(p)) {
+        let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
+        let swap = |a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
+        match annots {
+            Some(Object::Ref(ar)) => {
+                let a = doc.get(ar).as_array().cloned().unwrap_or_default();
+                doc.set(ar, Object::Array(swap(a)));
+            }
+            Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
+            _ => {}
+        }
+    }
+    Ok(kr)
+}
diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs
--- a/crates/forms/src/author/tree.rs
+++ b/crates/forms/src/author/tree.rs
@@ -187,7 +187,7 @@ pub(super) fn clash(name: &str, other: &str) -> Option<String> {
 }
 
 /// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field).
-fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
+pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
     let d = doc.get(holder).as_dict().cloned().unwrap_or_default();
     d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default()
 }
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -4577,3 +4577,275 @@ fn set_values_reads_the_form_once_not_per_value() {
     let reads = form_reads() - before;
     assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
 }
+
+/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`).
+fn two_radio_groups(a: &str, b: &str) -> Document {
+    let mut doc = one_page();
+    let radio = |export: &str| NewField::Radio { group: None, export: export.into() };
+    assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1");
+    assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2");
+    doc
+}
+
+fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result<String, FormError> {
+    set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() })
+}
+
+fn page_widgets(doc: &Document) -> Vec<ObjRef> {
+    let page = page_refs(doc)[0];
+    let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap();
+    doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect()
+}
+
+/// #4949: a radio button given its group's name is one of the group's buttons, so the group can
+/// be built up by naming buttons alike, as in Acrobat.
+#[test]
+fn a_radio_button_renamed_to_a_groups_name_joins_the_group() {
+    let mut doc = two_radio_groups("Yes", "No");
+    set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap();
+    let before = page_widgets(&doc);
+    assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1");
+    assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were");
+    let mut doc = reopen(&doc);
+    let all = fields(&doc);
+    assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Group1"]);
+    let group = field(&all, "Group1");
+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);
+    assert_eq!(group.value, ["Yes"], "the group keeps its value");
+    assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Yes".into()), Some("Off".into())]);
+    assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::<Vec<_>>());
+    // Choosing the moved button turns the other off: they are one group.
+    set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap();
+    let group = field(&fields(&doc), "Group1").clone();
+    assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Off".into()), Some("No".into())]);
+}
+
+/// A joining button whose export value a button of the group already has takes the next free
+/// "ChoiceN", in its appearance states too, so the two can be chosen apart.
+#[test]
+fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() {
+    let mut doc = two_radio_groups("Choice1", "Choice1");
+    rename_to(&mut doc, "Group2", "Group1").unwrap();
+    let doc = reopen(&doc);
+    let all = fields(&doc);
+    let group = field(&all, "Group1");
+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2"]);
+    let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();
+    let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap();
+    let normal = doc.resolve(&normal).as_dict().unwrap().clone();
+    assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1"));
+}
+
+/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it.
+#[test]
+fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() {
+    let mut doc = two_radio_groups("Yes", "No");
+    let group = field(&fields(&doc), "Group1").obj;
+    let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap();
+    let held = doc.add(kids);
+    doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap();
+    rename_to(&mut doc, "Group2", "Group1").unwrap();
+    let doc = reopen(&doc);
+    let all = fields(&doc);
+    assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);
+}
+
+/// Buttons of the joining group that share an export value (a button repeated on another page,
+/// on together) still share it after the join, under one fresh name.
+#[test]
+fn joining_radio_buttons_that_share_a_value_keep_sharing_it() {
+    let mut doc = two_radio_groups("Choice1", "Choice1");
+    let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
+    let mut p = pdfcraft_cos::Dict::new();
+    p.set(b"Type".to_vec(), Object::name("Page"));
+    p.set(b"Parent".to_vec(), Object::Ref(pages));
+    p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
+    let r = doc.add(Object::Dict(p));
+    doc.update_dict(pages, |d| {
+        let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone();
+        kids.push(Object::Ref(r));
+        d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
+        d.set(b"Kids".to_vec(), Object::Array(kids));
+    })
+    .unwrap();
+    assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1);
+    rename_to(&mut doc, "Group2", "Group1").unwrap();
+    let doc = reopen(&doc);
+    let all = fields(&doc);
+    let group = field(&all, "Group1");
+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2", "Choice2"]);
+}
+
+/// One page holding radio fields that are each their own only widget, as other writers make
+/// one-button groups: `(name, export value, x)`.
+fn merged_radios(specs: &[(&str, &str, f64)]) -> Document {
+    let mut doc = one_page();
+    let page = page_refs(&doc)[0];
+    let af = crate::author::ensure_form(&mut doc).unwrap();
+    let mut made = Vec::new();
+    for &(name, state, x) in specs {
+        let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));
+        let mut n = pdfcraft_cos::Dict::new();
+        n.set(state.as_bytes().to_vec(), Object::Ref(look));
+        n.set(b"Off".to_vec(), Object::Ref(look));
+        let mut ap = pdfcraft_cos::Dict::new();
+        ap.set(b"N".to_vec(), Object::Dict(n));
+        let mut d = pdfcraft_cos::Dict::new();
+        d.set(b"Type".to_vec(), Object::name("Annot"));
+        d.set(b"Subtype".to_vec(), Object::name("Widget"));
+        d.set(b"FT".to_vec(), Object::name("Btn"));
+        d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO)));
+        d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name));
+        d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()]));
+        d.set(b"P".to_vec(), Object::Ref(page));
+        d.set(b"AP".to_vec(), Object::Dict(ap));
+        d.set(b"AS".to_vec(), Object::name("Off"));
+        let r = doc.add(Object::Dict(d));
+        doc.update_dict(af, |a| {
+            let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default();
+            list.push(Object::Ref(r));
+            a.set(b"Fields".to_vec(), Object::Array(list));
+        })
+        .unwrap();
+        made.push(r);
+    }
+    doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap();
+    doc
+}
+
+/// A radio field that is its own (only) widget, as other writers make one-button groups, can
+/// both join a group and be joined.
+#[test]
+fn radio_fields_that_are_their_own_widget_join_and_are_joined() {
+    let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
+    assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo");
+    let doc = reopen(&doc);
+    let all = fields(&doc);
+    assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["solo"]);
+    let group = field(&all, "solo");
+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["A", "B"]);
+    // The joined field's widget became a kid; the moved one shed its field entries.
+    let kids: Vec<ObjRef> = group.widgets.iter().map(|w| w.obj).collect();
+    assert!(!kids.contains(&group.obj));
+    let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();
+    assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj));
+    let mut on_page = page_widgets(&doc);
+    on_page.sort();
+    let mut kids = kids;
+    kids.sort();
+    assert_eq!(on_page, kids, "the page lists the widgets, not the field");
+}
+
+/// A group that is its own only widget keeps its button's actions when another button joins
+/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own.
+#[test]
+fn a_joined_merged_groups_button_keeps_its_actions() {
+    let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
+    let solo = field(&fields(&doc), "solo").obj;
+    let js = |code: &str| {
+        let mut a = pdfcraft_cos::Dict::new();
+        a.set(b"S".to_vec(), Object::name("JavaScript"));
+        a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));
+        Object::Dict(a)
+    };
+    let mut aa = pdfcraft_cos::Dict::new();
+    aa.set(b"Fo".to_vec(), js("focus();"));
+    aa.set(b"V".to_vec(), js("validate();"));
+    let aa = doc.add(Object::Dict(aa));
+    doc.update_dict(solo, |d| {
+        d.set(b"A".to_vec(), js("up();"));
+        d.set(b"AA".to_vec(), Object::Ref(aa));
+    })
+    .unwrap();
+    rename_to(&mut doc, "other", "solo").unwrap();
+    let doc = reopen(&doc);
+    assert_eq!(
+        field_actions(&doc, "solo").unwrap(),
+        [(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))]
+    );
+    let group = field(&fields(&doc), "solo").clone();
+    let fd = doc.get(group.obj).as_dict().unwrap().clone();
+    let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone();
+    assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger");
+    let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();
+    let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();
+    assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V"));
+}
+
+/// A joining button that was its own field keeps its widget triggers and drops the field's.
+#[test]
+fn a_joining_merged_button_sheds_its_fields_triggers() {
+    let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
+    let other = field(&fields(&doc), "other").obj;
+    let js = |code: &str| {
+        let mut a = pdfcraft_cos::Dict::new();
+        a.set(b"S".to_vec(), Object::name("JavaScript"));
+        a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));
+        Object::Dict(a)
+    };
+    let mut aa = pdfcraft_cos::Dict::new();
+    aa.set(b"U".to_vec(), js("up();"));
+    aa.set(b"V".to_vec(), js("validate();"));
+    aa.set(b"C".to_vec(), js("calc();"));
+    let aa = doc.add(Object::Dict(aa));
+    doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap();
+    rename_to(&mut doc, "other", "solo").unwrap();
+    let doc = reopen(&doc);
+    let group = field(&fields(&doc), "solo").clone();
+    let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();
+    let wd = doc.get(moved.obj).as_dict().unwrap().clone();
+    let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();
+    assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays");
+    let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
+    let other = field(&fields(&once), "other").obj;
+    let mut aa = pdfcraft_cos::Dict::new();
+    aa.set(b"V".to_vec(), js("validate();"));
+    once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap();
+    rename_to(&mut once, "other", "solo").unwrap();
+    let once = reopen(&once);
+    let group = field(&fields(&once), "solo").clone();
+    let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();
+    let wd = once.get(moved.obj).as_dict().unwrap().clone();
+    assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA");
+}
+
+/// However many "ChoiceN" names a group already uses, a joining button with a clashing export
+/// value gets a free one, so the two buttons can still be chosen apart.
+#[test]
+fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() {
+    let mut doc = two_radio_groups("Choice1", "Choice1");
+    let group = field(&fields(&doc), "Group1").clone();
+    let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();
+    let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));
+    let mut kids = vec![Object::Ref(group.widgets[0].obj)];
+    for i in 2..=10_000 {
+        let mut n = pdfcraft_cos::Dict::new();
+        n.set(format!("Choice{i}").into_bytes(), Object::Ref(look));
+        n.set(b"Off".to_vec(), Object::Ref(look));
+        let mut ap = pdfcraft_cos::Dict::new();
+        ap.set(b"N".to_vec(), Object::Dict(n));
+        let mut w = model.clone();
+        w.set(b"AP".to_vec(), Object::Dict(ap));
+        w.remove(b"P");
+        kids.push(Object::Ref(doc.add(Object::Dict(w))));
+    }
+    doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();
+    rename_to(&mut doc, "Group2", "Group1").unwrap();
+    let group = field(&fields(&doc), "Group1").clone();
+    let states: Vec<String> = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect();
+    assert_eq!(states.len(), 10_001);
+    assert_eq!(states.last().map(String::as_str), Some("Choice10001"));
+}
+
+/// Only a radio button joins by name: other clashes are refused as before, and a locked group
+/// isn't changed.
+#[test]
+fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() {
+    let mut doc = two_radio_groups("Yes", "No");
+    add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap();
+    assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group");
+    assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field");
+    set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap();
+    assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group");
+    assert_eq!(fields(&doc).len(), 3);
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1527,6 +1527,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
         let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());
         // A rename also renames the field in the formulas that read it.
         let before = props.name.as_ref().and_then(|_| form(app));
+        // The widget being edited, by object: a radio button that joins a group by its new name
+        // is appended after the group's own buttons, so its index changes.
+        let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj));
         if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
             && let Some(i) = app.active
         {
@@ -1535,7 +1538,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
             let typed = d.name.trim();
             let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default();
             let name = format!("{prefix}{typed}");
-            if let (Some(before), Some(after)) = (before, form(app)) {
+            let after = form(app);
+            let index =
+                widget.and_then(|obj| after.as_ref()?.iter().find(|f| f.name == name)?.widgets.iter().position(|w| w.obj == obj)).unwrap_or(d.widget);
+            if let (Some(before), Some(after)) = (before, after) {
                 let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();
                 if n > 0 {
                     app.notify(crate::i18n::trn(
@@ -1546,7 +1552,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
                     ));
                 }
             }
-            app.views[i].prepare.selected = Some((name, d.widget));
+            app.views[i].prepare.selected = Some((name, index));
         }
     }
     if props_now && let Some(d) = app.comment_props.take() {
diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs
--- a/crates/ui-egui/tests/it/prepare.rs
+++ b/crates/ui-egui/tests/it/prepare.rs
@@ -775,14 +775,18 @@ fn actions_tab_edits_the_script_of_an_existing_action() {
 /// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur
 /// runs another script.
 fn chained_action_fixture() -> Vec<u8> {
-    let objs = [
+    pdf_of(&[
         "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
         "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
         "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
         "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>",
         "<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>",
         "<< /S /URI /URI (https://example.org) >>",
-    ];
+    ])
+}
+
+/// A PDF of `objs`, numbered from 1, with the first as its catalog.
+fn pdf_of(objs: &[&str]) -> Vec<u8> {
     let mut out = b"%PDF-1.7\n".to_vec();
     let mut offs = Vec::new();
     for (i, o) in objs.iter().enumerate() {
@@ -798,6 +802,65 @@ fn chained_action_fixture() -> Vec<u8> {
     out
 }
 
+/// Two one-button radio groups placed apart on page 1: "Group1" (export `A`) and "Group2" (`B`).
+fn two_radio_groups_fixture() -> Vec<u8> {
+    let widget = |parent: u32, x: u32, state: &str| {
+        format!(
+            "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [{x} 300 {} 314] /P 3 0 R /AS /Off /AP << /N << /{state} 8 0 R /Off 8 0 R >> >> >>",
+            x + 14
+        )
+    };
+    let (a, b) = (widget(4, 40, "A"), widget(6, 120, "B"));
+    pdf_of(&[
+        "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] >> >>",
+        "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
+        "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 7 0 R] >>",
+        "<< /FT /Btn /Ff 49152 /T (Group1) /Kids [5 0 R] >>",
+        &a,
+        "<< /FT /Btn /Ff 49152 /T (Group2) /Kids [7 0 R] >>",
+        &b,
+        "<< /Length 3 >>\nstream\n0 g\nendstream",
+    ])
+}
+
+/// #4949: renaming a radio button to another group's name moves it into that group, after the
+/// group's own buttons. The moved button stays the selected one, so a nudge moves it and not the
+/// group's first button, and undo and redo take the rename and the nudge back and forth.
+#[test]
+fn a_radio_button_joining_a_group_by_name_stays_selected() {
+    let mut h = harness_with("radios.pdf", two_radio_groups_fixture());
+    h.state_mut().execute("form.prepare");
+    h.run_steps(2);
+    let rects = |h: &Harness<'static, PdfCraftApp>| {
+        let s = h.state();
+        let form = &s.session.get(s.views[0].id).unwrap().form;
+        form.iter().flat_map(|f| f.widgets.iter().map(|w| w.rect)).collect::<Vec<_>>()
+    };
+    let start = rects(&h);
+    h.state_mut().open_field_props("Group2", 0);
+    h.run_steps(2);
+    h.state_mut().field_props.as_mut().expect("open").name = "Group1".into();
+    h.get_by_label("OK").click();
+    h.run_steps(3);
+    assert_eq!(names(&h), ["Group1"]);
+    assert_eq!(h.state().views[0].prepare.selected, Some(("Group1".to_string(), 1)), "the moved button");
+    h.key_press(egui::Key::ArrowRight);
+    h.run_steps(2);
+    let nudged = rects(&h);
+    assert_eq!(nudged[0], start[0], "the group's own button stays");
+    assert!((nudged[1][0] - start[1][0] - 1.0).abs() < 0.01, "{:?} → {:?}", start[1], nudged[1]);
+    h.state_mut().execute("edit.undo");
+    h.state_mut().execute("edit.undo");
+    h.run_steps(2);
+    assert_eq!(names(&h), ["Group1", "Group2"]);
+    assert_eq!(rects(&h), start);
+    h.state_mut().execute("edit.redo");
+    h.state_mut().execute("edit.redo");
+    h.run_steps(2);
+    assert_eq!(names(&h), ["Group1"]);
+    assert_eq!(rects(&h), nudged);
+}
+
 #[test]
 fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() {
     use egui_kittest::kittest::NodeT;
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -4847,8 +4847,8 @@ milestone = "M6"
 status = "shipped"
 commands = ["form.add.radio"]
 tools = ["form_add_field"]
-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"]
-notes = "new group Group1… or join the selected group with the next ChoiceN"
+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::a_radio_button_renamed_to_a_groups_name_joins_the_group", "crates/forms/src/tests.rs::a_joining_radio_button_with_a_taken_export_value_takes_a_free_one", "crates/forms/src/tests.rs::radio_fields_that_are_their_own_widget_join_and_are_joined", "crates/automation/tests/it/automation.rs::renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools"]
+notes = "new group Group1… or join the selected group with the next ChoiceN; renaming a radio button (Properties or form_set_props) to another radio group's name moves its buttons into that group (#4949), a clashing export value becoming the next free ChoiceN; refused when either group lists its values in /Opt; a button's export value is not yet editable in Properties"
 
 [[feature]]
 id = "form.check-box-style"
