diff --git a/docs/content/splices-toggles.md b/docs/content/splices-toggles.md
index 84f8bf3..35bb526 100644
--- a/docs/content/splices-toggles.md
+++ b/docs/content/splices-toggles.md
@@ -90,6 +90,21 @@ html! {
 # ;
 ```
 
+### Splices in attribute name
+
+You can also use splices in the attribute name:
+
+```rust
+let tuple = ("hx-get", "/pony");
+# let _ = maud::
+html! {
+    button (tuple.0)=(tuple.1) {
+        "Get a pony!"
+    }
+}
+# ;
+```
+
 ### What can be spliced?
 
 You can splice any value that implements [`Render`][Render].
@@ -145,7 +160,7 @@ html! {
 
 ### Optional attributes with values: `title=[Some("value")]`
 
-Add optional attributes to an element using `attr=[value]` syntax, with *square* brackets.
+Add optional attributes to an element using `attr=[value]` syntax, with _square_ brackets.
 These are only rendered if the value is `Some<T>`, and entirely omitted if the value is `None`.
 
 ```rust
diff --git a/maud/tests/splices.rs b/maud/tests/splices.rs
index 8665e84..7e2b6d0 100644
--- a/maud/tests/splices.rs
+++ b/maud/tests/splices.rs
@@ -73,6 +73,16 @@ fn locals() {
     assert_eq!(result.into_string(), "Pinkie Pie");
 }
 
+#[test]
+fn attribute_name() {
+    let tuple = ("hx-get", "/pony");
+    let result = html! { button (tuple.0)=(tuple.1) { "Get a pony!" } };
+    assert_eq!(
+        result.into_string(),
+        r#"<button hx-get="/pony">Get a pony!</button>"#
+    );
+}
+
 /// An example struct, for testing purposes only
 struct Creature {
     name: &'static str,
diff --git a/maud_macros/src/ast.rs b/maud_macros/src/ast.rs
index b95665e..61a5c16 100644
--- a/maud_macros/src/ast.rs
+++ b/maud_macros/src/ast.rs
@@ -153,13 +153,13 @@ impl Special {
 
 #[derive(Debug)]
 pub struct NamedAttr {
-    pub name: TokenStream,
+    pub name: AttrName,
     pub attr_type: AttrType,
 }
 
 impl NamedAttr {
     fn span(&self) -> SpanRange {
-        let name_span = span_tokens(self.name.clone());
+        let name_span = span_tokens(self.name.tokens());
         if let Some(attr_type_span) = self.attr_type.span() {
             name_span.join_range(attr_type_span)
         } else {
@@ -168,6 +168,31 @@ impl NamedAttr {
     }
 }
 
+#[derive(Debug, Clone)]
+pub enum AttrName {
+    Fixed { value: TokenStream },
+    Splice { expr: TokenStream },
+}
+
+impl AttrName {
+    pub fn tokens(&self) -> TokenStream {
+        match self {
+            AttrName::Fixed { value } => value.clone(),
+            AttrName::Splice { expr, .. } => expr.clone(),
+        }
+    }
+}
+
+impl std::fmt::Display for AttrName {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            AttrName::Fixed { value } => f.write_str(&value.to_string())?,
+            AttrName::Splice { expr, .. } => f.write_str(&expr.to_string())?,
+        };
+        Ok(())
+    }
+}
+
 #[derive(Debug)]
 pub enum AttrType {
     Normal { value: Markup },
diff --git a/maud_macros/src/generate.rs b/maud_macros/src/generate.rs
index ee27a55..f432a50 100644
--- a/maud_macros/src/generate.rs
+++ b/maud_macros/src/generate.rs
@@ -123,12 +123,19 @@ impl Generator {
         build.push_escaped(&name_to_string(name));
     }
 
+    fn attr_name(&self, name: AttrName, build: &mut Builder) {
+        match name {
+            AttrName::Fixed { value } => self.name(value, build),
+            AttrName::Splice { expr, .. } => self.splice(expr, build),
+        }
+    }
+
     fn attrs(&self, attrs: Vec<Attr>, build: &mut Builder) {
         for NamedAttr { name, attr_type } in desugar_attrs(attrs) {
             match attr_type {
                 AttrType::Normal { value } => {
                     build.push_str(" ");
-                    self.name(name, build);
+                    self.attr_name(name, build);
                     build.push_str("=\"");
                     self.markup(value, build);
                     build.push_str("\"");
@@ -140,7 +147,7 @@ impl Generator {
                     let body = {
                         let mut build = self.builder();
                         build.push_str(" ");
-                        self.name(name, &mut build);
+                        self.attr_name(name, &mut build);
                         build.push_str("=\"");
                         self.splice(inner_value.clone(), &mut build);
                         build.push_str("\"");
@@ -150,7 +157,7 @@ impl Generator {
                 }
                 AttrType::Empty { toggler: None } => {
                     build.push_str(" ");
-                    self.name(name, build);
+                    self.attr_name(name, build);
                 }
                 AttrType::Empty {
                     toggler: Some(Toggler { cond, .. }),
@@ -158,7 +165,7 @@ impl Generator {
                     let body = {
                         let mut build = self.builder();
                         build.push_str(" ");
-                        self.name(name, &mut build);
+                        self.attr_name(name, &mut build);
                         build.finish()
                     };
                     build.push_tokens(quote!(if (#cond) { #body }));
@@ -224,7 +231,9 @@ fn desugar_classes_or_ids(
         });
     }
     Some(NamedAttr {
-        name: TokenStream::from(TokenTree::Ident(Ident::new(attr_name, Span::call_site()))),
+        name: AttrName::Fixed {
+            value: TokenStream::from(TokenTree::Ident(Ident::new(attr_name, Span::call_site()))),
+        },
         attr_type: AttrType::Normal {
             value: Markup::Block(Block {
                 markups,
diff --git a/maud_macros/src/parse.rs b/maud_macros/src/parse.rs
index 05af289..ffb4e04 100644
--- a/maud_macros/src/parse.rs
+++ b/maud_macros/src/parse.rs
@@ -13,7 +13,7 @@ pub fn parse(input: TokenStream) -> Vec<ast::Markup> {
 #[derive(Clone)]
 struct Parser {
     /// If we're inside an attribute, then this contains the attribute name.
-    current_attr: Option<String>,
+    current_attr: Option<ast::AttrName>,
     input: <TokenStream as IntoIterator>::IntoIter,
 }
 
@@ -580,48 +580,7 @@ impl Parser {
         let mut attrs = Vec::new();
         loop {
             if let Some(name) = self.try_namespaced_name() {
-                // Attribute
-                match self.peek() {
-                    // Non-empty attribute
-                    Some(TokenTree::Punct(ref punct)) if punct.as_char() == '=' => {
-                        self.advance();
-                        // Parse a value under an attribute context
-                        assert!(self.current_attr.is_none());
-                        self.current_attr = Some(ast::name_to_string(name.clone()));
-                        let attr_type = match self.attr_toggler() {
-                            Some(toggler) => ast::AttrType::Optional { toggler },
-                            None => {
-                                let value = self.markup();
-                                ast::AttrType::Normal { value }
-                            }
-                        };
-                        self.current_attr = None;
-                        attrs.push(ast::Attr::Named {
-                            named_attr: ast::NamedAttr { name, attr_type },
-                        });
-                    }
-                    // Empty attribute (legacy syntax)
-                    Some(TokenTree::Punct(ref punct)) if punct.as_char() == '?' => {
-                        self.advance();
-                        let toggler = self.attr_toggler();
-                        attrs.push(ast::Attr::Named {
-                            named_attr: ast::NamedAttr {
-                                name: name.clone(),
-                                attr_type: ast::AttrType::Empty { toggler },
-                            },
-                        });
-                    }
-                    // Empty attribute (new syntax)
-                    _ => {
-                        let toggler = self.attr_toggler();
-                        attrs.push(ast::Attr::Named {
-                            named_attr: ast::NamedAttr {
-                                name: name.clone(),
-                                attr_type: ast::AttrType::Empty { toggler },
-                            },
-                        });
-                    }
-                }
+                attrs.push(self.attr(ast::AttrName::Fixed { value: name }));
             } else {
                 match self.peek() {
                     // Class shorthand
@@ -644,6 +603,18 @@ impl Parser {
                             name,
                         });
                     }
+                    // Spliced attribute name
+                    Some(TokenTree::Group(ref group))
+                        if group.delimiter() == Delimiter::Parenthesis =>
+                    {
+                        match self.markup() {
+                            ast::Markup::Splice { expr, .. } => {
+                                attrs.push(self.attr(ast::AttrName::Splice { expr }));
+                            }
+                            // If it's not a splice, backtrack and bail out
+                            _ => break,
+                        }
+                    }
                     // If it's not a valid attribute, backtrack and bail out
                     _ => break,
                 }
@@ -665,7 +636,7 @@ impl Parser {
                 ast::Attr::Id { .. } => "id".to_string(),
                 ast::Attr::Named { named_attr } => named_attr
                     .name
-                    .clone()
+                    .tokens()
                     .into_iter()
                     .map(|token| token.to_string())
                     .collect(),
@@ -685,6 +656,50 @@ impl Parser {
         attrs
     }
 
+    fn attr(&mut self, name: ast::AttrName) -> ast::Attr {
+        match self.peek() {
+            // Non-empty attribute
+            Some(TokenTree::Punct(ref punct)) if punct.as_char() == '=' => {
+                self.advance();
+                // Parse a value under an attribute context
+                assert!(self.current_attr.is_none());
+                self.current_attr = Some(name.clone());
+                let attr_type = match self.attr_toggler() {
+                    Some(toggler) => ast::AttrType::Optional { toggler },
+                    None => {
+                        let value = self.markup();
+                        ast::AttrType::Normal { value }
+                    }
+                };
+                self.current_attr = None;
+                ast::Attr::Named {
+                    named_attr: ast::NamedAttr { name, attr_type },
+                }
+            }
+            // Empty attribute (legacy syntax)
+            Some(TokenTree::Punct(ref punct)) if punct.as_char() == '?' => {
+                self.advance();
+                let toggler = self.attr_toggler();
+                ast::Attr::Named {
+                    named_attr: ast::NamedAttr {
+                        name,
+                        attr_type: ast::AttrType::Empty { toggler },
+                    },
+                }
+            }
+            // Empty attribute (new syntax)
+            _ => {
+                let toggler = self.attr_toggler();
+                ast::Attr::Named {
+                    named_attr: ast::NamedAttr {
+                        name,
+                        attr_type: ast::AttrType::Empty { toggler },
+                    },
+                }
+            }
+        }
+    }
+
     /// Parses the name of a class or ID.
     fn class_or_id_name(&mut self) -> ast::Markup {
         if let Some(symbol) = self.try_name() {