diff --git a/config/binds.go b/config/binds.go
index 3b9510cd..9591ee49 100644
--- a/config/binds.go
+++ b/config/binds.go
@@ -7,12 +7,14 @@ import (
 	"io"
 	"os"
 	"path"
+	"path/filepath"
 	"regexp"
 	"strings"
 	"unicode"
 	"unicode/utf8"
 
 	"git.sr.ht/~rjarry/aerc/lib/log"
+	"git.sr.ht/~rjarry/aerc/lib/xdg"
 	"git.sr.ht/~rockorager/vaxis"
 	"github.com/go-ini/ini"
 )
@@ -104,7 +106,24 @@ func defaultBindsConfig() *BindingConfig {
 
 var Binds = defaultBindsConfig()
 
-func parseBindsFromFile(root string, filename string) error {
+func parseBindsFromFile(_ string, filename string) error {
+	filename = xdg.ExpandHome(filename)
+	absolute, err := filepath.Abs(filename)
+	if err != nil {
+		return err
+	}
+	return parseBindsFile(absolute, make(map[string]bool))
+}
+
+func parseBindsFile(filename string, stack map[string]bool) error {
+	filename = filepath.Clean(filename)
+
+	if stack[filename] {
+		return fmt.Errorf("cyclic key bindings include: %s", filename)
+	}
+	stack[filename] = true
+	defer delete(stack, filename)
+
 	log.Debugf("Parsing key bindings configuration from %s", filename)
 	binds, err := ini.LoadSources(ini.LoadOptions{
 		KeyValueDelimiters: "=",
@@ -117,6 +136,26 @@ func parseBindsFromFile(root string, filename string) error {
 		return err
 	}
 
+	defaultSection, err := binds.GetSection(ini.DefaultSection)
+	if err != nil {
+		return err
+	}
+	if include, err := defaultSection.GetKey("$include"); err == nil {
+		for _, name := range strings.Split(include.String(), ",") {
+			name = strings.TrimSpace(name)
+			if name == "" {
+				continue
+			}
+			name = xdg.ExpandHome(name)
+			if !filepath.IsAbs(name) {
+				name = filepath.Join(filepath.Dir(filename), name)
+			}
+			if err := parseBindsFile(name, stack); err != nil {
+				return fmt.Errorf("include %s from %s: %w", name, filename, err)
+			}
+		}
+	}
+
 	baseGroups := map[string]**KeyBindings{
 		"default":           &Binds.Global,
 		"compose":           &Binds.Compose,
@@ -178,6 +217,8 @@ func LoadBindingSection(sec *ini.Section) (*KeyBindings, error) {
 		value, annotation, _ := strings.Cut(k.String(), " # ")
 		value = strings.TrimSpace(value)
 		switch key {
+		case "$include":
+			continue
 		case "$ex":
 			strokes, err := ParseKeyStrokes(value)
 			if err != nil {
diff --git a/config/binds_test.go b/config/binds_test.go
index a47da41c..e3182537 100644
--- a/config/binds_test.go
+++ b/config/binds_test.go
@@ -2,12 +2,57 @@ package config
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"git.sr.ht/~rockorager/vaxis"
 	"github.com/stretchr/testify/assert"
 )
 
+func TestBindsInclude(t *testing.T) {
+	oldBinds := Binds
+	t.Cleanup(func() { Binds = oldBinds })
+	Binds = defaultBindsConfig()
+
+	dir := t.TempDir()
+	write := func(name, content string) {
+		err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)
+		assert.NoError(t, err)
+	}
+	write("base.conf", "a = :base<Enter>\n[messages]\nq = :base<Enter>\n")
+	write("extra.conf", "a = :extra<Enter>\n[messages]\nq = :extra<Enter>\n")
+	write("binds.conf", "$include = base.conf, extra.conf\na = :local<Enter>\n")
+
+	assert.NoError(t, parseBindsFromFile(dir, filepath.Join(dir, "binds.conf")))
+	assertBinding := func(bindings *KeyBindings, input, output string) {
+		in, err := ParseKeyStrokes(input)
+		assert.NoError(t, err)
+		want, err := ParseKeyStrokes(output)
+		assert.NoError(t, err)
+		result, got := bindings.GetBinding(in)
+		assert.Equal(t, BindingSearchResult(BINDING_FOUND), result)
+		assert.Equal(t, want, got)
+	}
+	assertBinding(Binds.Global, "a", ":local<Enter>")
+	assertBinding(Binds.MessageList, "q", ":extra<Enter>")
+}
+
+func TestBindsIncludeCycle(t *testing.T) {
+	oldBinds := Binds
+	t.Cleanup(func() { Binds = oldBinds })
+	Binds = defaultBindsConfig()
+
+	dir := t.TempDir()
+	assert.NoError(t, os.WriteFile(
+		filepath.Join(dir, "one.conf"), []byte("$include = two.conf\n"), 0o600))
+	assert.NoError(t, os.WriteFile(
+		filepath.Join(dir, "two.conf"), []byte("$include = one.conf\n"), 0o600))
+
+	err := parseBindsFromFile(dir, filepath.Join(dir, "one.conf"))
+	assert.ErrorContains(t, err, "cyclic key bindings include")
+}
+
 func TestGetBinding(t *testing.T) {
 	assert := assert.New(t)
 
diff --git a/doc/aerc-binds.5.scd b/doc/aerc-binds.5.scd
index 1f508ef8..68155c7a 100644
--- a/doc/aerc-binds.5.scd
+++ b/doc/aerc-binds.5.scd
@@ -30,6 +30,23 @@ in a similar fashion.
 You may configure different keybindings for different contexts by writing them
 into different *[sections]* of the ini file.
 
+# INCLUDES
+
+Additional binding files can be loaded with the *$include* special option in the
+global section:
+
+```
+$include = /usr/share/aerc/binds.conf
+```
+
+Multiple files can be separated with commas. Relative paths are resolved from
+the directory containing the file with the *$include* option. Paths beginning
+with _~/_ are expanded to the user's home directory. Included files are loaded
+in the order listed, before the including file. Therefore, bindings in the
+including file override bindings from included files.
+
+Includes may be nested. Cyclic includes are rejected.
+
 # CONTEXTS
 
 The available contexts are:
