diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go
index 965b748ac9..7c295247c0 100644
--- a/models/forgejo_migrations/migrate.go
+++ b/models/forgejo_migrations/migrate.go
@@ -56,6 +56,8 @@ var migrations = []*Migration{
 	NewMigration("Modify the `release`.`note` content to remove SSH signatures", forgejo_v1_22.RemoveSSHSignaturesFromReleaseNotes),
 	// v8 -> v9
 	NewMigration("Add the `apply_to_admins` column to the `protected_branch` table", forgejo_v1_22.AddApplyToAdminsSetting),
+	// v9 -> v10
+	NewMigration("Add pronouns to user", forgejo_v1_22.AddPronounsToUser),
 }
 
 // GetCurrentDBVersion returns the current Forgejo database version.
diff --git a/models/forgejo_migrations/v1_22/v10.go b/models/forgejo_migrations/v1_22/v10.go
new file mode 100644
index 0000000000..819800ae71
--- /dev/null
+++ b/models/forgejo_migrations/v1_22/v10.go
@@ -0,0 +1,17 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_22 //nolint
+
+import (
+	"xorm.io/xorm"
+)
+
+func AddPronounsToUser(x *xorm.Engine) error {
+	type User struct {
+		ID       int64 `xorm:"pk autoincr"`
+		Pronouns string
+	}
+
+	return x.Sync(&User{})
+}
diff --git a/models/user/user.go b/models/user/user.go
index 4aef30ca56..ff85c2cfa0 100644
--- a/models/user/user.go
+++ b/models/user/user.go
@@ -95,6 +95,7 @@ type User struct {
 	Type        UserType
 	Location    string
 	Website     string
+	Pronouns    string
 	Rands       string `xorm:"VARCHAR(32)"`
 	Salt        string `xorm:"VARCHAR(32)"`
 	Language    string `xorm:"VARCHAR(5)"`
diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go
index f7c6d10ba0..ad86f4ca03 100644
--- a/modules/structs/admin_user.go
+++ b/modules/structs/admin_user.go
@@ -41,6 +41,7 @@ type EditUserOption struct {
 	MustChangePassword      *bool   `json:"must_change_password"`
 	Website                 *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
 	Location                *string `json:"location" binding:"MaxSize(50)"`
+	Pronouns                *string `json:"pronouns" binding:"MaxSize(50)"`
 	Description             *string `json:"description" binding:"MaxSize(255)"`
 	Active                  *bool   `json:"active"`
 	Admin                   *bool   `json:"admin"`
diff --git a/modules/structs/user.go b/modules/structs/user.go
index 4e13669ad2..82b565e5e7 100644
--- a/modules/structs/user.go
+++ b/modules/structs/user.go
@@ -41,6 +41,8 @@ type User struct {
 	ProhibitLogin bool `json:"prohibit_login"`
 	// the user's location
 	Location string `json:"location"`
+	// the user's pronouns
+	Pronouns string `json:"pronouns"`
 	// the user's website
 	Website string `json:"website"`
 	// the user's description
@@ -71,6 +73,7 @@ type UserSettings struct {
 	Website             string `json:"website"`
 	Description         string `json:"description"`
 	Location            string `json:"location"`
+	Pronouns            string `json:"pronouns"`
 	Language            string `json:"language"`
 	Theme               string `json:"theme"`
 	DiffViewStyle       string `json:"diff_view_style"`
@@ -87,6 +90,7 @@ type UserSettingsOptions struct {
 	Website             *string `json:"website" binding:"OmitEmpty;ValidUrl;MaxSize(255)"`
 	Description         *string `json:"description" binding:"MaxSize(255)"`
 	Location            *string `json:"location" binding:"MaxSize(50)"`
+	Pronouns            *string `json:"pronouns" binding:"MaxSize(50)"`
 	Language            *string `json:"language"`
 	Theme               *string `json:"theme"`
 	DiffViewStyle       *string `json:"diff_view_style"`
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index c727a9cc12..95643bbf7e 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -705,6 +705,9 @@ password_username_disabled = Non-local users are not allowed to change their use
 full_name = Full name
 website = Website
 location = Location
+pronouns = Pronouns
+pronouns_custom = Custom
+pronouns_unspecified = Unspecified
 update_theme = Change theme
 update_profile = Update profile
 update_language = Change language
diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go
index 87a5b28fad..12da8a9597 100644
--- a/routers/api/v1/admin/user.go
+++ b/routers/api/v1/admin/user.go
@@ -236,6 +236,7 @@ func EditUser(ctx *context.APIContext) {
 		Website:                 optional.FromPtr(form.Website),
 		Location:                optional.FromPtr(form.Location),
 		Description:             optional.FromPtr(form.Description),
+		Pronouns:                optional.FromPtr(form.Pronouns),
 		IsActive:                optional.FromPtr(form.Active),
 		IsAdmin:                 optional.FromPtr(form.Admin),
 		Visibility:              optional.FromNonDefault(api.VisibilityModes[form.Visibility]),
diff --git a/routers/api/v1/user/settings.go b/routers/api/v1/user/settings.go
index f594eb211c..bfd24013db 100644
--- a/routers/api/v1/user/settings.go
+++ b/routers/api/v1/user/settings.go
@@ -48,6 +48,7 @@ func UpdateUserSettings(ctx *context.APIContext) {
 	opts := &user_service.UpdateOptions{
 		FullName:            optional.FromPtr(form.FullName),
 		Description:         optional.FromPtr(form.Description),
+		Pronouns:            optional.FromPtr(form.Pronouns),
 		Website:             optional.FromPtr(form.Website),
 		Location:            optional.FromPtr(form.Location),
 		Language:            optional.FromPtr(form.Language),
diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go
index b93668c5a2..3dcf0d2aa8 100644
--- a/routers/web/admin/users.go
+++ b/routers/web/admin/users.go
@@ -435,6 +435,7 @@ func EditUserPost(ctx *context.Context) {
 		FullName:                optional.Some(form.FullName),
 		Website:                 optional.Some(form.Website),
 		Location:                optional.Some(form.Location),
+		Pronouns:                optional.Some(form.Pronouns),
 		IsActive:                optional.Some(form.Active),
 		IsAdmin:                 optional.Some(form.Admin),
 		AllowGitHook:            optional.Some(form.AllowGitHook),
diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
index 4e5c380ed9..a39c118ddd 100644
--- a/routers/web/user/setting/profile.go
+++ b/routers/web/user/setting/profile.go
@@ -12,6 +12,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"slices"
 	"strings"
 
 	"code.gitea.io/gitea/models/avatars"
@@ -40,12 +41,16 @@ const (
 	tplSettingsRepositories base.TplName = "user/settings/repos"
 )
 
+// must be kept in sync with `web_src/js/features/user-settings.js`
+var recognisedPronouns = []string{"", "he/him", "she/her", "they/them", "it/its", "any pronouns"}
+
 // Profile render user's profile page
 func Profile(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("settings.profile")
 	ctx.Data["PageIsSettingsProfile"] = true
 	ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
 	ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
+	ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns)
 
 	ctx.HTML(http.StatusOK, tplSettingsProfile)
 }
@@ -56,6 +61,7 @@ func ProfilePost(ctx *context.Context) {
 	ctx.Data["PageIsSettingsProfile"] = true
 	ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
 	ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
+	ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns)
 
 	if ctx.HasError() {
 		ctx.HTML(http.StatusOK, tplSettingsProfile)
@@ -90,6 +96,7 @@ func ProfilePost(ctx *context.Context) {
 		FullName:            optional.Some(form.FullName),
 		KeepEmailPrivate:    optional.Some(form.KeepEmailPrivate),
 		Description:         optional.Some(form.Description),
+		Pronouns:            optional.Some(form.Pronouns),
 		Website:             optional.Some(form.Website),
 		Location:            optional.Some(form.Location),
 		Visibility:          optional.Some(form.Visibility),
diff --git a/services/convert/user.go b/services/convert/user.go
index b8e791ed28..98db53705b 100644
--- a/services/convert/user.go
+++ b/services/convert/user.go
@@ -56,6 +56,7 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap
 		Created:     user.CreatedUnix.AsTime(),
 		Restricted:  user.IsRestricted,
 		Location:    user.Location,
+		Pronouns:    user.Pronouns,
 		Website:     user.Website,
 		Description: user.Description,
 		// counter's
@@ -89,6 +90,7 @@ func User2UserSettings(user *user_model.User) api.UserSettings {
 		FullName:            user.FullName,
 		Website:             user.Website,
 		Location:            user.Location,
+		Pronouns:            user.Pronouns,
 		Language:            user.Language,
 		Description:         user.Description,
 		Theme:               user.Theme,
diff --git a/services/forms/admin.go b/services/forms/admin.go
index 81276f8f46..7d46904440 100644
--- a/services/forms/admin.go
+++ b/services/forms/admin.go
@@ -42,6 +42,7 @@ type AdminEditUserForm struct {
 	Website                 string `binding:"ValidUrl;MaxSize(255)"`
 	Location                string `binding:"MaxSize(50)"`
 	Language                string `binding:"MaxSize(5)"`
+	Pronouns                string `binding:"MaxSize(50)"`
 	MaxRepoCreation         int
 	Active                  bool
 	Admin                   bool
diff --git a/services/forms/user_form.go b/services/forms/user_form.go
index 3290179092..196b092990 100644
--- a/services/forms/user_form.go
+++ b/services/forms/user_form.go
@@ -218,6 +218,7 @@ type UpdateProfileForm struct {
 	KeepEmailPrivate    bool
 	Website             string `binding:"ValidSiteUrl;MaxSize(255)"`
 	Location            string `binding:"MaxSize(50)"`
+	Pronouns            string `binding:"MaxSize(50)"`
 	Description         string `binding:"MaxSize(255)"`
 	Visibility          structs.VisibleType
 	KeepActivityPrivate bool
diff --git a/services/user/update.go b/services/user/update.go
index e96ab4274a..1bdbf13f0d 100644
--- a/services/user/update.go
+++ b/services/user/update.go
@@ -22,6 +22,7 @@ type UpdateOptions struct {
 	Website                      optional.Option[string]
 	Location                     optional.Option[string]
 	Description                  optional.Option[string]
+	Pronouns                     optional.Option[string]
 	AllowGitHook                 optional.Option[bool]
 	AllowImportLocal             optional.Option[bool]
 	MaxRepoCreation              optional.Option[int]
@@ -54,6 +55,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
 
 		cols = append(cols, "full_name")
 	}
+	if opts.Pronouns.Has() {
+		u.Pronouns = opts.Pronouns.Value()
+
+		cols = append(cols, "pronouns")
+	}
 	if opts.Website.Has() {
 		u.Website = opts.Website.Value()
 
diff --git a/templates/admin/user/edit.tmpl b/templates/admin/user/edit.tmpl
index 41b00defb4..8203a2a076 100644
--- a/templates/admin/user/edit.tmpl
+++ b/templates/admin/user/edit.tmpl
@@ -61,6 +61,10 @@
 					<label for="full_name">{{ctx.Locale.Tr "settings.full_name"}}</label>
 					<input id="full_name" name="full_name" value="{{.User.FullName}}" maxlength="100">
 				</div>
+				<div class="field">
+					<label for="pronouns">{{ctx.Locale.Tr "settings.pronouns"}}</label>
+					<input id="pronouns" name="pronouns" value="{{.User.Pronouns}}" maxlength="50">
+				</div>
 				<div class="required field {{if .Err_Email}}error{{end}}">
 					<label for="email">{{ctx.Locale.Tr "email"}}</label>
 					<input id="email" name="email" type="email" value="{{.User.Email}}" autofocus required>
diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl
index e229ca8365..95277e2f78 100644
--- a/templates/shared/user/profile_big_avatar.tmpl
+++ b/templates/shared/user/profile_big_avatar.tmpl
@@ -13,7 +13,7 @@
 	</div>
 	<div class="content gt-word-break profile-avatar-name">
 		{{if .ContextUser.FullName}}<span class="header text center">{{.ContextUser.FullName}}</span>{{end}}
-		<span class="username text center">{{.ContextUser.Name}} {{if .IsAdmin}}
+		<span class="username text center">{{.ContextUser.Name}}{{if .ContextUser.Pronouns}} ยท {{.ContextUser.Pronouns}}{{end}} {{if .IsAdmin}}
 					<a class="muted" href="{{AppSubUrl}}/admin/users/{{.ContextUser.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}">
 						{{svg "octicon-gear" 18}}
 					</a>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 181c564f4a..bcf370b3fb 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -20331,6 +20331,10 @@
           "type": "boolean",
           "x-go-name": "ProhibitLogin"
         },
+        "pronouns": {
+          "type": "string",
+          "x-go-name": "Pronouns"
+        },
         "restricted": {
           "type": "boolean",
           "x-go-name": "Restricted"
@@ -23830,6 +23834,11 @@
           "type": "boolean",
           "x-go-name": "ProhibitLogin"
         },
+        "pronouns": {
+          "description": "the user's pronouns",
+          "type": "string",
+          "x-go-name": "Pronouns"
+        },
         "restricted": {
           "description": "Is user restricted",
           "type": "boolean",
@@ -23905,6 +23914,10 @@
           "type": "string",
           "x-go-name": "Location"
         },
+        "pronouns": {
+          "type": "string",
+          "x-go-name": "Pronouns"
+        },
         "theme": {
           "type": "string",
           "x-go-name": "Theme"
@@ -23953,6 +23966,10 @@
           "type": "string",
           "x-go-name": "Location"
         },
+        "pronouns": {
+          "type": "string",
+          "x-go-name": "Pronouns"
+        },
         "theme": {
           "type": "string",
           "x-go-name": "Theme"
diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl
index aaaf8f30db..e596fa3496 100644
--- a/templates/user/settings/profile.tmpl
+++ b/templates/user/settings/profile.tmpl
@@ -21,6 +21,36 @@
 					<label for="full_name">{{ctx.Locale.Tr "settings.full_name"}}</label>
 					<input id="full_name" name="full_name" value="{{.SignedUser.FullName}}" maxlength="100">
 				</div>
+				<div class="inline field">
+					<span class="inline field"><label for="pronouns">{{ctx.Locale.Tr "settings.pronouns"}}</label></span>
+					<div id="pronouns-dropdown" style="display: none" class="ui selection dropdown">
+						<input type="hidden" value="{{.SignedUser.Pronouns}}">
+						<div class="text">
+							{{if .PronounsAreCustom}}
+								{{ctx.Locale.Tr "settings.pronouns_custom"}}
+							{{else if eq "" .SignedUser.Pronouns}}
+								{{ctx.Locale.Tr "settings.pronouns_unspecified"}}
+							{{else}}
+								{{.SignedUser.Pronouns}}
+							{{end}}
+						</div>
+						{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+						<div class="menu">
+							<div class="item{{if eq "" .SignedUser.Pronouns}} active selected{{end}}" data-value=""><i>{{ctx.Locale.Tr "settings.pronouns_unspecified"}}</i></div>
+							<div class="item{{if eq "he/him" .SignedUser.Pronouns}} active selected{{end}}" data-value="he/him">he/him</div>
+							<div class="item{{if eq "she/her" .SignedUser.Pronouns}} active selected{{end}}" data-value="she/her">she/her</div>
+							<div class="item{{if eq "they/them" .SignedUser.Pronouns}} active selected{{end}}" data-value="they/them">they/them</div>
+							<div class="item{{if eq "it/its" .SignedUser.Pronouns}} active selected{{end}}" data-value="it/its">it/its</div>
+							<div class="item{{if eq "any pronouns" .SignedUser.Pronouns}} active selected{{end}}" data-value="any pronouns">any pronouns</div>
+							{{if .PronounsAreCustom}}
+								<div class="item active selected" data-value="{{.SignedUser.Pronouns}}"><i>{{ctx.Locale.Tr "settings.pronouns_custom"}}</i></div>
+							{{else}}
+								<div class="item" data-value="!"><i>{{ctx.Locale.Tr "settings.pronouns_custom"}}</i></div>
+							{{end}}
+						</div>
+					</div>
+					<input id="pronouns-custom" name="pronouns" value="{{.SignedUser.Pronouns}}" maxlength="50">
+				</div>
 				<div class="field {{if .Err_Email}}error{{end}}">
 					<label>{{ctx.Locale.Tr "email"}}</label>
 					<p id="signed-user-email">{{.SignedUser.Email}}</p>
diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go
index b4b99a1917..3a3932bdc2 100644
--- a/tests/integration/user_test.go
+++ b/tests/integration/user_test.go
@@ -1,4 +1,5 @@
 // Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
 // SPDX-License-Identifier: MIT
 
 package integration
@@ -6,6 +7,7 @@ package integration
 import (
 	"fmt"
 	"net/http"
+	"strings"
 	"testing"
 
 	auth_model "code.gitea.io/gitea/models/auth"
@@ -428,3 +430,184 @@ func TestUserHints(t *testing.T) {
 		})
 	})
 }
+
+func TestUserPronouns(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	session := loginUser(t, "user2")
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+	adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+	adminSession := loginUser(t, adminUser.Name)
+	adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeWriteAdmin)
+
+	t.Run("API", func(t *testing.T) {
+		t.Run("user", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token)
+			resp := MakeRequest(t, req, http.StatusOK)
+
+			// We check the raw JSON, because we want to test the response, not
+			// what it decodes into. Contents doesn't matter, we're testing the
+			// presence only.
+			assert.Contains(t, resp.Body.String(), `"pronouns":`)
+		})
+
+		t.Run("users/{username}", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "GET", "/api/v1/users/user2")
+			resp := MakeRequest(t, req, http.StatusOK)
+
+			// We check the raw JSON, because we want to test the response, not
+			// what it decodes into. Contents doesn't matter, we're testing the
+			// presence only.
+			assert.Contains(t, resp.Body.String(), `"pronouns":`)
+		})
+
+		t.Run("user/settings", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			// Set pronouns first
+			pronouns := "they/them"
+			req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
+				Pronouns: &pronouns,
+			}).AddTokenAuth(token)
+			resp := MakeRequest(t, req, http.StatusOK)
+
+			// Verify the response
+			var user *api.UserSettings
+			DecodeJSON(t, resp, &user)
+			assert.Equal(t, pronouns, user.Pronouns)
+
+			// Verify retrieving the settings again
+			req = NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token)
+			resp = MakeRequest(t, req, http.StatusOK)
+
+			DecodeJSON(t, resp, &user)
+			assert.Equal(t, pronouns, user.Pronouns)
+		})
+
+		t.Run("admin/users/{username}", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			// Set the pronouns for user2
+			pronouns := "she/her"
+			req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user2", &api.EditUserOption{
+				LoginName: "user2",
+				SourceID:  0,
+				Pronouns:  &pronouns,
+			}).AddTokenAuth(adminToken)
+			resp := MakeRequest(t, req, http.StatusOK)
+
+			// Verify the API response
+			var user *api.User
+			DecodeJSON(t, resp, &user)
+			assert.Equal(t, pronouns, user.Pronouns)
+
+			// Verify via user2 too
+			req = NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token)
+			resp = MakeRequest(t, req, http.StatusOK)
+			DecodeJSON(t, resp, &user)
+			assert.Equal(t, pronouns, user.Pronouns)
+		})
+	})
+
+	t.Run("UI", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		// Set the pronouns to a known state via the API
+		pronouns := "she/her"
+		req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
+			Pronouns: &pronouns,
+		}).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusOK)
+
+		t.Run("profile view", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "GET", "/user2")
+			resp := MakeRequest(t, req, http.StatusOK)
+			htmlDoc := NewHTMLParser(t, resp.Body)
+
+			userNameAndPronouns := strings.TrimSpace(htmlDoc.Find(".profile-avatar-name .username").Text())
+			assert.Contains(t, userNameAndPronouns, pronouns)
+		})
+
+		t.Run("settings", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "GET", "/user/settings")
+			resp := session.MakeRequest(t, req, http.StatusOK)
+			htmlDoc := NewHTMLParser(t, resp.Body)
+
+			// Check that the field is present
+			pronounField, has := htmlDoc.Find(`input[name="pronouns"]`).Attr("value")
+			assert.True(t, has)
+			assert.Equal(t, pronouns, pronounField)
+
+			// Check that updating the field works
+			newPronouns := "they/them"
+			req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+				"_csrf":    GetCSRF(t, session, "/user/settings"),
+				"pronouns": newPronouns,
+			})
+			session.MakeRequest(t, req, http.StatusSeeOther)
+
+			user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+			assert.Equal(t, newPronouns, user2.Pronouns)
+		})
+
+		t.Run("admin settings", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+			req := NewRequestf(t, "GET", "/admin/users/%d/edit", user2.ID)
+			resp := adminSession.MakeRequest(t, req, http.StatusOK)
+			htmlDoc := NewHTMLParser(t, resp.Body)
+
+			// Check that the pronouns field is present
+			pronounField, has := htmlDoc.Find(`input[name="pronouns"]`).Attr("value")
+			assert.True(t, has)
+			assert.NotEmpty(t, pronounField)
+
+			// Check that updating the field works
+			newPronouns := "it/its"
+			editURI := fmt.Sprintf("/admin/users/%d/edit", user2.ID)
+			req = NewRequestWithValues(t, "POST", editURI, map[string]string{
+				"_csrf":      GetCSRF(t, adminSession, editURI),
+				"login_type": "0-0",
+				"login_name": user2.LoginName,
+				"email":      user2.Email,
+				"pronouns":   newPronouns,
+			})
+			adminSession.MakeRequest(t, req, http.StatusSeeOther)
+
+			user2New := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+			assert.Equal(t, newPronouns, user2New.Pronouns)
+		})
+	})
+
+	t.Run("unspecified", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		// Set the pronouns to Unspecified (an empty string) via the API
+		pronouns := ""
+		req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user2", &api.EditUserOption{
+			LoginName: "user2",
+			SourceID:  0,
+			Pronouns:  &pronouns,
+		}).AddTokenAuth(adminToken)
+		MakeRequest(t, req, http.StatusOK)
+
+		// Verify that the profile page does not display any pronouns, nor the separator
+		req = NewRequest(t, "GET", "/user2")
+		resp := MakeRequest(t, req, http.StatusOK)
+		htmlDoc := NewHTMLParser(t, resp.Body)
+
+		userName := strings.TrimSpace(htmlDoc.Find(".profile-avatar-name .username").Text())
+		assert.EqualValues(t, userName, "user2")
+	})
+}
diff --git a/web_src/css/user.css b/web_src/css/user.css
index 33ffa1eabc..e96598768b 100644
--- a/web_src/css/user.css
+++ b/web_src/css/user.css
@@ -157,3 +157,7 @@
 .notifications-item:hover .notifications-updated {
   display: none;
 }
+
+#pronouns-dropdown, #pronouns-custom {
+  width: 140px;
+}
\ No newline at end of file
diff --git a/web_src/js/features/user-settings.js b/web_src/js/features/user-settings.js
index 2d8c53e457..717ef945e1 100644
--- a/web_src/js/features/user-settings.js
+++ b/web_src/js/features/user-settings.js
@@ -1,5 +1,35 @@
 import {hideElem, showElem} from '../utils/dom.js';
 
+function onPronounsDropdownUpdate() {
+  const pronounsCustom = document.getElementById('pronouns-custom');
+  const pronounsDropdown = document.getElementById('pronouns-dropdown');
+  const pronounsInput = pronounsDropdown.querySelector('input');
+  // must be kept in sync with `routers/web/user/setting/profile.go`
+  const isCustom = !(
+    pronounsInput.value === '' ||
+    pronounsInput.value === 'he/him' ||
+    pronounsInput.value === 'she/her' ||
+    pronounsInput.value === 'they/them' ||
+    pronounsInput.value === 'it/its' ||
+    pronounsInput.value === 'any pronouns'
+  );
+  if (isCustom) {
+    if (pronounsInput.value === '!') {
+      pronounsCustom.value = '';
+    } else {
+      pronounsCustom.value = pronounsInput.value;
+    }
+    pronounsCustom.style.display = '';
+  } else {
+    pronounsCustom.style.display = 'none';
+  }
+}
+function onPronounsCustomUpdate() {
+  const pronounsCustom = document.getElementById('pronouns-custom');
+  const pronounsInput = document.querySelector('#pronouns-dropdown input');
+  pronounsInput.value = pronounsCustom.value;
+}
+
 export function initUserSettings() {
   if (!document.querySelectorAll('.user.settings.profile').length) return;
 
@@ -16,4 +46,18 @@ export function initUserSettings() {
       hideElem(promptRedirect);
     }
   });
+
+  const pronounsDropdown = document.getElementById('pronouns-dropdown');
+  const pronounsCustom = document.getElementById('pronouns-custom');
+  const pronounsInput = pronounsDropdown.querySelector('input');
+
+  // If JS is disabled, the page will show the custom input, as the dropdown requires JS to work.
+  // JS progressively enhances the input by adding a dropdown, but it works regardless.
+  pronounsCustom.removeAttribute('name');
+  pronounsInput.setAttribute('name', 'pronouns');
+  pronounsDropdown.style.display = '';
+
+  onPronounsDropdownUpdate();
+  pronounsInput.addEventListener('change', onPronounsDropdownUpdate);
+  pronounsCustom.addEventListener('input', onPronounsCustomUpdate);
 }