diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml
index 4dea8add13..174345ff5a 100644
--- a/models/fixtures/issue.yml
+++ b/models/fixtures/issue.yml
@@ -287,3 +287,20 @@
   created_unix: 1602935696
   updated_unix: 1602935696
   is_locked: false
+
+-
+  id: 18
+  repo_id: 55
+  index: 1
+  poster_id: 2
+  original_author_id: 0
+  name: issue for scoped labels
+  content: content
+  milestone_id: 0
+  priority: 0
+  is_closed: false
+  is_pull: false
+  num_comments: 0
+  created_unix: 946684830
+  updated_unix: 978307200
+  is_locked: false
diff --git a/models/fixtures/label.yml b/models/fixtures/label.yml
index 57bf804457..ab4d5ef944 100644
--- a/models/fixtures/label.yml
+++ b/models/fixtures/label.yml
@@ -4,6 +4,7 @@
   org_id: 0
   name: label1
   color: '#abcdef'
+  exclusive: false
   num_issues: 2
   num_closed_issues: 0
 
@@ -13,6 +14,7 @@
   org_id: 0
   name: label2
   color: '#000000'
+  exclusive: false
   num_issues: 1
   num_closed_issues: 1
 
@@ -22,6 +24,7 @@
   org_id: 3
   name: orglabel3
   color: '#abcdef'
+  exclusive: false
   num_issues: 0
   num_closed_issues: 0
 
@@ -31,6 +34,7 @@
   org_id: 3
   name: orglabel4
   color: '#000000'
+  exclusive: false
   num_issues: 1
   num_closed_issues: 0
 
@@ -40,5 +44,46 @@
   org_id: 0
   name: pull-test-label
   color: '#000000'
+  exclusive: false
+  num_issues: 0
+  num_closed_issues: 0
+
+-
+  id: 6
+  repo_id: 55
+  org_id: 0
+  name: unscoped_label
+  color: '#000000'
+  exclusive: false
+  num_issues: 0
+  num_closed_issues: 0
+
+-
+  id: 7
+  repo_id: 55
+  org_id: 0
+  name: scope/label1
+  color: '#000000'
+  exclusive: true
+  num_issues: 0
+  num_closed_issues: 0
+
+-
+  id: 8
+  repo_id: 55
+  org_id: 0
+  name: scope/label2
+  color: '#000000'
+  exclusive: true
+  num_issues: 0
+  num_closed_issues: 0
+
+-
+  id: 9
+  repo_id: 55
+  org_id: 0
+  name: scope/subscope/label2
+  color: '#000000'
+  exclusive: true
   num_issues: 0
   num_closed_issues: 0
diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml
index ef3cfbbbec..58f9b919ac 100644
--- a/models/fixtures/repository.yml
+++ b/models/fixtures/repository.yml
@@ -1622,3 +1622,15 @@
   is_archived: false
   is_private: true
   status: 0
+
+-
+  id: 55
+  owner_id: 2
+  owner_name: user2
+  lower_name: scoped_label
+  name: scoped_label
+  is_empty: false
+  is_archived: false
+  is_private: true
+  num_issues: 1
+  status: 0
diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml
index 63a5e0f890..0a1d85b48b 100644
--- a/models/fixtures/user.yml
+++ b/models/fixtures/user.yml
@@ -66,7 +66,7 @@
   num_followers: 2
   num_following: 1
   num_stars: 2
-  num_repos: 10
+  num_repos: 11
   num_teams: 0
   num_members: 0
   visibility: 0
diff --git a/models/issues/issue.go b/models/issues/issue.go
index b1c7fdbf7e..9d7dea0177 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -538,6 +538,31 @@ func (ts labelSorter) Swap(i, j int) {
 	[]*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i]
 }
 
+// Ensure only one label of a given scope exists, with labels at the end of the
+// array getting preference over earlier ones.
+func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label {
+	validLabels := make([]*Label, 0, len(labels))
+
+	for i, label := range labels {
+		scope := label.ExclusiveScope()
+		if scope != "" {
+			foundOther := false
+			for _, otherLabel := range labels[i+1:] {
+				if otherLabel.ExclusiveScope() == scope {
+					foundOther = true
+					break
+				}
+			}
+			if foundOther {
+				continue
+			}
+		}
+		validLabels = append(validLabels, label)
+	}
+
+	return validLabels
+}
+
 // ReplaceIssueLabels removes all current labels and add new labels to the issue.
 // Triggers appropriate WebHooks, if any.
 func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
@@ -555,6 +580,8 @@ func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (e
 		return err
 	}
 
+	labels = RemoveDuplicateExclusiveLabels(labels)
+
 	sort.Sort(labelSorter(labels))
 	sort.Sort(labelSorter(issue.Labels))
 
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
index de1da19ab9..3a83d8d2b7 100644
--- a/models/issues/issue_test.go
+++ b/models/issues/issue_test.go
@@ -25,7 +25,7 @@ import (
 func TestIssue_ReplaceLabels(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
-	testSuccess := func(issueID int64, labelIDs []int64) {
+	testSuccess := func(issueID int64, labelIDs, expectedLabelIDs []int64) {
 		issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID})
 		repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
 		doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
@@ -35,15 +35,20 @@ func TestIssue_ReplaceLabels(t *testing.T) {
 			labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID, RepoID: repo.ID})
 		}
 		assert.NoError(t, issues_model.ReplaceIssueLabels(issue, labels, doer))
-		unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(labelIDs))
-		for _, labelID := range labelIDs {
+		unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issueID}, len(expectedLabelIDs))
+		for _, labelID := range expectedLabelIDs {
 			unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID})
 		}
 	}
 
-	testSuccess(1, []int64{2})
-	testSuccess(1, []int64{1, 2})
-	testSuccess(1, []int64{})
+	testSuccess(1, []int64{2}, []int64{2})
+	testSuccess(1, []int64{1, 2}, []int64{1, 2})
+	testSuccess(1, []int64{}, []int64{})
+
+	// mutually exclusive scoped labels 7 and 8
+	testSuccess(18, []int64{6, 7}, []int64{6, 7})
+	testSuccess(18, []int64{7, 8}, []int64{8})
+	testSuccess(18, []int64{6, 8, 7}, []int64{6, 7})
 }
 
 func Test_GetIssueIDsByRepoID(t *testing.T) {
@@ -523,5 +528,5 @@ func TestCountIssues(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 	count, err := issues_model.CountIssues(db.DefaultContext, &issues_model.IssuesOptions{})
 	assert.NoError(t, err)
-	assert.EqualValues(t, 17, count)
+	assert.EqualValues(t, 18, count)
 }
diff --git a/models/issues/label.go b/models/issues/label.go
index dbb7a139ef..0dd12fb5c9 100644
--- a/models/issues/label.go
+++ b/models/issues/label.go
@@ -7,8 +7,6 @@ package issues
 import (
 	"context"
 	"fmt"
-	"html/template"
-	"math"
 	"regexp"
 	"strconv"
 	"strings"
@@ -89,6 +87,7 @@ type Label struct {
 	RepoID          int64 `xorm:"INDEX"`
 	OrgID           int64 `xorm:"INDEX"`
 	Name            string
+	Exclusive       bool
 	Description     string
 	Color           string `xorm:"VARCHAR(7)"`
 	NumIssues       int
@@ -128,18 +127,22 @@ func (label *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64)
 }
 
 // LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
-func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64) {
+func (label *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) {
 	var labelQuerySlice []string
 	labelSelected := false
 	labelID := strconv.FormatInt(label.ID, 10)
-	for _, s := range currentSelectedLabels {
+	labelScope := label.ExclusiveScope()
+	for i, s := range currentSelectedLabels {
 		if s == label.ID {
 			labelSelected = true
 		} else if -s == label.ID {
 			labelSelected = true
 			label.IsExcluded = true
 		} else if s != 0 {
-			labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
+			// Exclude other labels in the same scope from selection
+			if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] {
+				labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
+			}
 		}
 	}
 	if !labelSelected {
@@ -159,49 +162,43 @@ func (label *Label) BelongsToRepo() bool {
 	return label.RepoID > 0
 }
 
-// SrgbToLinear converts a component of an sRGB color to its linear intensity
-// See: https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation_(sRGB_to_CIE_XYZ)
-func SrgbToLinear(color uint8) float64 {
-	flt := float64(color) / 255
-	if flt <= 0.04045 {
-		return flt / 12.92
+// Get color as RGB values in 0..255 range
+func (label *Label) ColorRGB() (float64, float64, float64, error) {
+	color, err := strconv.ParseUint(label.Color[1:], 16, 64)
+	if err != nil {
+		return 0, 0, 0, err
 	}
-	return math.Pow((flt+0.055)/1.055, 2.4)
+
+	r := float64(uint8(0xFF & (uint32(color) >> 16)))
+	g := float64(uint8(0xFF & (uint32(color) >> 8)))
+	b := float64(uint8(0xFF & uint32(color)))
+	return r, g, b, nil
 }
 
-// Luminance returns the luminance of an sRGB color
-func Luminance(color uint32) float64 {
-	r := SrgbToLinear(uint8(0xFF & (color >> 16)))
-	g := SrgbToLinear(uint8(0xFF & (color >> 8)))
-	b := SrgbToLinear(uint8(0xFF & color))
-
-	// luminance ratios for sRGB
-	return 0.2126*r + 0.7152*g + 0.0722*b
-}
-
-// LuminanceThreshold is the luminance at which white and black appear to have the same contrast
-// i.e. x such that 1.05 / (x + 0.05) = (x + 0.05) / 0.05
-// i.e. math.Sqrt(1.05*0.05) - 0.05
-const LuminanceThreshold float64 = 0.179
-
-// ForegroundColor calculates the text color for labels based
-// on their background color.
-func (label *Label) ForegroundColor() template.CSS {
+// Determine if label text should be light or dark to be readable on background color
+func (label *Label) UseLightTextColor() bool {
 	if strings.HasPrefix(label.Color, "#") {
-		if color, err := strconv.ParseUint(label.Color[1:], 16, 64); err == nil {
-			// NOTE: see web_src/js/components/ContextPopup.vue for similar implementation
-			luminance := Luminance(uint32(color))
-
-			// prefer white or black based upon contrast
-			if luminance < LuminanceThreshold {
-				return template.CSS("#fff")
-			}
-			return template.CSS("#000")
+		if r, g, b, err := label.ColorRGB(); err == nil {
+			// Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast
+			// In the future WCAG 3 APCA may be a better solution
+			brightness := (0.299*r + 0.587*g + 0.114*b) / 255
+			return brightness < 0.35
 		}
 	}
 
-	// default to black
-	return template.CSS("#000")
+	return false
+}
+
+// Return scope substring of label name, or empty string if none exists
+func (label *Label) ExclusiveScope() string {
+	if !label.Exclusive {
+		return ""
+	}
+	lastIndex := strings.LastIndex(label.Name, "/")
+	if lastIndex == -1 || lastIndex == 0 || lastIndex == len(label.Name)-1 {
+		return ""
+	}
+	return label.Name[:lastIndex]
 }
 
 // NewLabel creates a new label
@@ -253,7 +250,7 @@ func UpdateLabel(l *Label) error {
 	if !LabelColorPattern.MatchString(l.Color) {
 		return fmt.Errorf("bad color code: %s", l.Color)
 	}
-	return updateLabelCols(db.DefaultContext, l, "name", "description", "color")
+	return updateLabelCols(db.DefaultContext, l, "name", "description", "color", "exclusive")
 }
 
 // DeleteLabel delete a label
@@ -620,6 +617,29 @@ func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_m
 	return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
 }
 
+// Remove all issue labels in the given exclusive scope
+func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
+	scope := label.ExclusiveScope()
+	if scope == "" {
+		return nil
+	}
+
+	var toRemove []*Label
+	for _, issueLabel := range issue.Labels {
+		if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope {
+			toRemove = append(toRemove, issueLabel)
+		}
+	}
+
+	for _, issueLabel := range toRemove {
+		if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // NewIssueLabel creates a new issue-label relation.
 func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) {
 	if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) {
@@ -641,6 +661,10 @@ func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error
 		return nil
 	}
 
+	if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil {
+		return nil
+	}
+
 	if err = newIssueLabel(ctx, issue, label, doer); err != nil {
 		return err
 	}
diff --git a/models/issues/label_test.go b/models/issues/label_test.go
index 239e328d47..0e45e0db0b 100644
--- a/models/issues/label_test.go
+++ b/models/issues/label_test.go
@@ -4,7 +4,6 @@
 package issues_test
 
 import (
-	"html/template"
 	"testing"
 
 	"code.gitea.io/gitea/models/db"
@@ -25,13 +24,22 @@ func TestLabel_CalOpenIssues(t *testing.T) {
 	assert.EqualValues(t, 2, label.NumOpenIssues)
 }
 
-func TestLabel_ForegroundColor(t *testing.T) {
+func TestLabel_TextColor(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 	label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
-	assert.Equal(t, template.CSS("#000"), label.ForegroundColor())
+	assert.False(t, label.UseLightTextColor())
 
 	label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2})
-	assert.Equal(t, template.CSS("#fff"), label.ForegroundColor())
+	assert.True(t, label.UseLightTextColor())
+}
+
+func TestLabel_ExclusiveScope(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
+	assert.Equal(t, "scope", label.ExclusiveScope())
+
+	label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 9})
+	assert.Equal(t, "scope/subscope", label.ExclusiveScope())
 }
 
 func TestNewLabels(t *testing.T) {
@@ -266,6 +274,7 @@ func TestUpdateLabel(t *testing.T) {
 		Color:       "#ffff00",
 		Name:        "newLabelName",
 		Description: label.Description,
+		Exclusive:   false,
 	}
 	label.Color = update.Color
 	label.Name = update.Name
@@ -323,6 +332,34 @@ func TestNewIssueLabel(t *testing.T) {
 	unittest.CheckConsistencyFor(t, &issues_model.Issue{}, &issues_model.Label{})
 }
 
+func TestNewIssueExclusiveLabel(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18})
+	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	otherLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 6})
+	exclusiveLabelA := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
+	exclusiveLabelB := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8})
+
+	// coexisting regular and exclusive label
+	assert.NoError(t, issues_model.NewIssueLabel(issue, otherLabel, doer))
+	assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer))
+	unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
+	unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
+
+	// exclusive label replaces existing one
+	assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelB, doer))
+	unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
+	unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
+	unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
+
+	// exclusive label replaces existing one again
+	assert.NoError(t, issues_model.NewIssueLabel(issue, exclusiveLabelA, doer))
+	unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: otherLabel.ID})
+	unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelA.ID})
+	unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: exclusiveLabelB.ID})
+}
+
 func TestNewIssueLabels(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 	label1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
diff --git a/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml b/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml
index d651c87d5b..085b7f0882 100644
--- a/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml
+++ b/models/migrations/fixtures/Test_DeleteOrphanedIssueLabels/label.yml
@@ -4,6 +4,7 @@
   org_id: 0
   name: label1
   color: '#abcdef'
+  exclusive: false
   num_issues: 2
   num_closed_issues: 0
 
@@ -13,6 +14,7 @@
   org_id: 0
   name: label2
   color: '#000000'
+  exclusive: false
   num_issues: 1
   num_closed_issues: 1
 -
@@ -21,6 +23,7 @@
   org_id:  3
   name: orglabel3
   color: '#abcdef'
+  exclusive: false
   num_issues: 0
   num_closed_issues: 0
 
@@ -30,6 +33,7 @@
   org_id: 3
   name: orglabel4
   color: '#000000'
+  exclusive: false
   num_issues: 1
   num_closed_issues: 0
 
@@ -39,5 +43,6 @@
   org_id: 0
   name: pull-test-label
   color: '#000000'
+  exclusive: false
   num_issues: 0
   num_closed_issues: 0
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 73c44f008a..c7497becd1 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -459,6 +459,8 @@ var migrations = []Migration{
 	NewMigration("Add card_type column to project table", v1_19.AddCardTypeToProjectTable),
 	// v242 -> v243
 	NewMigration("Alter gpg_key_import content TEXT field to MEDIUMTEXT", v1_19.AlterPublicGPGKeyImportContentFieldToMediumText),
+	// v243 -> v244
+	NewMigration("Add exclusive label", v1_19.AddExclusiveLabel),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v1_19/v244.go b/models/migrations/v1_19/v244.go
new file mode 100644
index 0000000000..55bbfafb2f
--- /dev/null
+++ b/models/migrations/v1_19/v244.go
@@ -0,0 +1,16 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_19 //nolint
+
+import (
+	"xorm.io/xorm"
+)
+
+func AddExclusiveLabel(x *xorm.Engine) error {
+	type Label struct {
+		Exclusive bool
+	}
+
+	return x.Sync(new(Label))
+}
diff --git a/modules/migration/label.go b/modules/migration/label.go
index 38f0eb10da..4927be3c0b 100644
--- a/modules/migration/label.go
+++ b/modules/migration/label.go
@@ -9,4 +9,5 @@ type Label struct {
 	Name        string `json:"name"`
 	Color       string `json:"color"`
 	Description string `json:"description"`
+	Exclusive   bool   `json:"exclusive"`
 }
diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go
index 5c622797f4..5bb6cc3b84 100644
--- a/modules/structs/issue_label.go
+++ b/modules/structs/issue_label.go
@@ -9,6 +9,8 @@ package structs
 type Label struct {
 	ID   int64  `json:"id"`
 	Name string `json:"name"`
+	// example: false
+	Exclusive bool `json:"exclusive"`
 	// example: 00aabb
 	Color       string `json:"color"`
 	Description string `json:"description"`
@@ -19,6 +21,8 @@ type Label struct {
 type CreateLabelOption struct {
 	// required:true
 	Name string `json:"name" binding:"Required"`
+	// example: false
+	Exclusive bool `json:"exclusive"`
 	// required:true
 	// example: #00aabb
 	Color       string `json:"color" binding:"Required"`
@@ -27,7 +31,10 @@ type CreateLabelOption struct {
 
 // EditLabelOption options for editing a label
 type EditLabelOption struct {
-	Name        *string `json:"name"`
+	Name *string `json:"name"`
+	// example: false
+	Exclusive *bool `json:"exclusive"`
+	// example: #00aabb
 	Color       *string `json:"color"`
 	Description *string `json:"description"`
 }
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 8f8f565c1f..4ffd0a5dee 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -7,10 +7,12 @@ package templates
 import (
 	"bytes"
 	"context"
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"html"
 	"html/template"
+	"math"
 	"mime"
 	"net/url"
 	"path/filepath"
@@ -382,6 +384,9 @@ func NewFuncMap() []template.FuncMap {
 			// the table is NOT sorted with this header
 			return ""
 		},
+		"RenderLabel": func(label *issues_model.Label) template.HTML {
+			return template.HTML(RenderLabel(label))
+		},
 		"RenderLabels": func(labels []*issues_model.Label, repoLink string) template.HTML {
 			htmlCode := `<span class="labels-list">`
 			for _, label := range labels {
@@ -389,8 +394,8 @@ func NewFuncMap() []template.FuncMap {
 				if label == nil {
 					continue
 				}
-				htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d' class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</a> ",
-					repoLink, label.ID, label.ForegroundColor(), label.Color, html.EscapeString(label.Description), RenderEmoji(label.Name))
+				htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ",
+					repoLink, label.ID, RenderLabel(label))
 			}
 			htmlCode += "</span>"
 			return template.HTML(htmlCode)
@@ -801,6 +806,67 @@ func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[str
 	return template.HTML(renderedText)
 }
 
+// RenderLabel renders a label
+func RenderLabel(label *issues_model.Label) string {
+	labelScope := label.ExclusiveScope()
+
+	textColor := "#111"
+	if label.UseLightTextColor() {
+		textColor = "#eee"
+	}
+
+	description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
+
+	if labelScope == "" {
+		// Regular label
+		return fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>",
+			textColor, label.Color, description, RenderEmoji(label.Name))
+	}
+
+	// Scoped label
+	scopeText := RenderEmoji(labelScope)
+	itemText := RenderEmoji(label.Name[len(labelScope)+1:])
+
+	itemColor := label.Color
+	scopeColor := label.Color
+	if r, g, b, err := label.ColorRGB(); err == nil {
+		// Make scope and item background colors slightly darker and lighter respectively.
+		// More contrast needed with higher luminance, empirically tweaked.
+		luminance := (0.299*r + 0.587*g + 0.114*b) / 255
+		contrast := 0.01 + luminance*0.06
+		// Ensure we add the same amount of contrast also near 0 and 1.
+		darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
+		lighten := contrast + math.Max(contrast-luminance, 0.0)
+		// Compute factor to keep RGB values proportional.
+		darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
+		lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
+
+		scopeBytes := []byte{
+			uint8(math.Min(math.Round(r*darkenFactor), 255)),
+			uint8(math.Min(math.Round(g*darkenFactor), 255)),
+			uint8(math.Min(math.Round(b*darkenFactor), 255)),
+		}
+		itemBytes := []byte{
+			uint8(math.Min(math.Round(r*lightenFactor), 255)),
+			uint8(math.Min(math.Round(g*lightenFactor), 255)),
+			uint8(math.Min(math.Round(b*lightenFactor), 255)),
+		}
+
+		itemColor = "#" + hex.EncodeToString(itemBytes)
+		scopeColor = "#" + hex.EncodeToString(scopeBytes)
+	}
+
+	return fmt.Sprintf("<span class='ui label scope-parent' title='%s'>"+
+		"<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+
+		"<div class='ui label scope-middle' style='background: linear-gradient(-80deg, %s 48%%, %s 52%% 0%%);'>&nbsp;</div>"+
+		"<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+
+		"</span>",
+		description,
+		textColor, scopeColor, scopeText,
+		itemColor, scopeColor,
+		textColor, itemColor, itemText)
+}
+
 // RenderEmoji renders html text with emoji post processors
 func RenderEmoji(text string) template.HTML {
 	renderedText, err := markup.RenderEmoji(template.HTMLEscapeString(text))
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 51ae8c7a02..411a585c81 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1395,9 +1395,12 @@ issues.sign_in_require_desc = <a href="%s">Sign in</a> to join this conversation
 issues.edit = Edit
 issues.cancel = Cancel
 issues.save = Save
-issues.label_title = Label name
-issues.label_description = Label description
-issues.label_color = Label color
+issues.label_title = Name
+issues.label_description = Description
+issues.label_color = Color
+issues.label_exclusive = Exclusive
+issues.label_exclusive_desc = Name the label <code>scope/item</code> to make it mutually exclusive with other <code>scope/</code> labels.
+issues.label_exclusive_warning = Any conflicting scoped labels will be removed when editing the labels of an issue or pull request.
 issues.label_count = %d labels
 issues.label_open_issues = %d open issues/pull requests
 issues.label_edit = Edit
diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go
index 5d0455cdd4..938fe79df6 100644
--- a/routers/api/v1/org/label.go
+++ b/routers/api/v1/org/label.go
@@ -94,6 +94,7 @@ func CreateLabel(ctx *context.APIContext) {
 
 	label := &issues_model.Label{
 		Name:        form.Name,
+		Exclusive:   form.Exclusive,
 		Color:       form.Color,
 		OrgID:       ctx.Org.Organization.ID,
 		Description: form.Description,
@@ -195,6 +196,9 @@ func EditLabel(ctx *context.APIContext) {
 	if form.Name != nil {
 		label.Name = *form.Name
 	}
+	if form.Exclusive != nil {
+		label.Exclusive = *form.Exclusive
+	}
 	if form.Color != nil {
 		label.Color = strings.Trim(*form.Color, " ")
 		if len(label.Color) == 6 {
diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go
index 411c0274e6..a06d26e837 100644
--- a/routers/api/v1/repo/label.go
+++ b/routers/api/v1/repo/label.go
@@ -156,6 +156,7 @@ func CreateLabel(ctx *context.APIContext) {
 
 	label := &issues_model.Label{
 		Name:        form.Name,
+		Exclusive:   form.Exclusive,
 		Color:       form.Color,
 		RepoID:      ctx.Repo.Repository.ID,
 		Description: form.Description,
@@ -218,6 +219,9 @@ func EditLabel(ctx *context.APIContext) {
 	if form.Name != nil {
 		label.Name = *form.Name
 	}
+	if form.Exclusive != nil {
+		label.Exclusive = *form.Exclusive
+	}
 	if form.Color != nil {
 		label.Color = strings.Trim(*form.Color, " ")
 		if len(label.Color) == 6 {
diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go
index 1c910a93a5..e96627762b 100644
--- a/routers/web/org/org_labels.go
+++ b/routers/web/org/org_labels.go
@@ -45,6 +45,7 @@ func NewLabel(ctx *context.Context) {
 	l := &issues_model.Label{
 		OrgID:       ctx.Org.Organization.ID,
 		Name:        form.Title,
+		Exclusive:   form.Exclusive,
 		Description: form.Description,
 		Color:       form.Color,
 	}
@@ -70,6 +71,7 @@ func UpdateLabel(ctx *context.Context) {
 	}
 
 	l.Name = form.Title
+	l.Exclusive = form.Exclusive
 	l.Description = form.Description
 	l.Color = form.Color
 	if err := issues_model.UpdateLabel(l); err != nil {
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 62565af50f..05ba26a70c 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -332,8 +332,24 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 		labels = append(labels, orgLabels...)
 	}
 
+	// Get the exclusive scope for every label ID
+	labelExclusiveScopes := make([]string, 0, len(labelIDs))
+	for _, labelID := range labelIDs {
+		foundExclusiveScope := false
+		for _, label := range labels {
+			if label.ID == labelID || label.ID == -labelID {
+				labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
+				foundExclusiveScope = true
+				break
+			}
+		}
+		if !foundExclusiveScope {
+			labelExclusiveScopes = append(labelExclusiveScopes, "")
+		}
+	}
+
 	for _, l := range labels {
-		l.LoadSelectedLabelsAfterClick(labelIDs)
+		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
 	}
 	ctx.Data["Labels"] = labels
 	ctx.Data["NumLabels"] = len(labels)
diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go
index 66e8920bd9..d4fece9f01 100644
--- a/routers/web/repo/issue_label.go
+++ b/routers/web/repo/issue_label.go
@@ -113,6 +113,7 @@ func NewLabel(ctx *context.Context) {
 	l := &issues_model.Label{
 		RepoID:      ctx.Repo.Repository.ID,
 		Name:        form.Title,
+		Exclusive:   form.Exclusive,
 		Description: form.Description,
 		Color:       form.Color,
 	}
@@ -138,6 +139,7 @@ func UpdateLabel(ctx *context.Context) {
 	}
 
 	l.Name = form.Title
+	l.Exclusive = form.Exclusive
 	l.Description = form.Description
 	l.Color = form.Color
 	if err := issues_model.UpdateLabel(l); err != nil {
@@ -175,7 +177,7 @@ func UpdateIssueLabel(ctx *context.Context) {
 				return
 			}
 		}
-	case "attach", "detach", "toggle":
+	case "attach", "detach", "toggle", "toggle-alt":
 		label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id"))
 		if err != nil {
 			if issues_model.IsErrRepoLabelNotExist(err) {
@@ -189,12 +191,18 @@ func UpdateIssueLabel(ctx *context.Context) {
 		if action == "toggle" {
 			// detach if any issues already have label, otherwise attach
 			action = "attach"
-			for _, issue := range issues {
-				if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) {
-					action = "detach"
-					break
+			if label.ExclusiveScope() == "" {
+				for _, issue := range issues {
+					if issues_model.HasIssueLabel(ctx, issue.ID, label.ID) {
+						action = "detach"
+						break
+					}
 				}
 			}
+		} else if action == "toggle-alt" {
+			// always detach with alt key pressed, to be able to remove
+			// scoped labels
+			action = "detach"
 		}
 
 		if action == "attach" {
diff --git a/services/convert/issue.go b/services/convert/issue.go
index dccf2f3727..e79fcfcccb 100644
--- a/services/convert/issue.go
+++ b/services/convert/issue.go
@@ -182,6 +182,7 @@ func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_m
 	result := &api.Label{
 		ID:          label.ID,
 		Name:        label.Name,
+		Exclusive:   label.Exclusive,
 		Color:       strings.TrimLeft(label.Color, "#"),
 		Description: label.Description,
 	}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index c1b5800968..ff0916f8e1 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -564,6 +564,7 @@ func (f *CreateMilestoneForm) Validate(req *http.Request, errs binding.Errors) b
 type CreateLabelForm struct {
 	ID          int64
 	Title       string `binding:"Required;MaxSize(50)" locale:"repo.issues.label_title"`
+	Exclusive   bool   `form:"exclusive"`
 	Description string `binding:"MaxSize(200)" locale:"repo.issues.label_description"`
 	Color       string `binding:"Required;MaxSize(7)" locale:"repo.issues.label_color"`
 }
diff --git a/services/migrations/main_test.go b/services/migrations/main_test.go
index 30875f6e5b..42c433fb00 100644
--- a/services/migrations/main_test.go
+++ b/services/migrations/main_test.go
@@ -59,6 +59,7 @@ func assertCommentsEqual(t *testing.T, expected, actual []*base.Comment) {
 
 func assertLabelEqual(t *testing.T, expected, actual *base.Label) {
 	assert.Equal(t, expected.Name, actual.Name)
+	assert.Equal(t, expected.Exclusive, actual.Exclusive)
 	assert.Equal(t, expected.Color, actual.Color)
 	assert.Equal(t, expected.Description, actual.Description)
 }
diff --git a/services/repository/template.go b/services/repository/template.go
index 13e0749869..8c75948c41 100644
--- a/services/repository/template.go
+++ b/services/repository/template.go
@@ -31,6 +31,7 @@ func GenerateIssueLabels(ctx context.Context, templateRepo, generateRepo *repo_m
 		newLabels = append(newLabels, &issues_model.Label{
 			RepoID:      generateRepo.ID,
 			Name:        templateLabel.Name,
+			Exclusive:   templateLabel.Exclusive,
 			Description: templateLabel.Description,
 			Color:       templateLabel.Color,
 		})
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 112e6be7ce..b25cf2526e 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -234,7 +234,7 @@
 						{{if or .Labels .Assignees}}
 						<div class="extra content labels-list gt-p-0 gt-pt-2">
 							{{range .Labels}}
-								<a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a>
+								<a target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel .}}</a>
 							{{end}}
 							<div class="right floated">
 								{{range .Assignees}}
diff --git a/templates/repo/issue/labels/edit_delete_label.tmpl b/templates/repo/issue/labels/edit_delete_label.tmpl
index a0479dde1b..450061e835 100644
--- a/templates/repo/issue/labels/edit_delete_label.tmpl
+++ b/templates/repo/issue/labels/edit_delete_label.tmpl
@@ -26,31 +26,45 @@
 		<form class="ui edit-label form ignore-dirty" action="{{$.Link}}/edit" method="post">
 			{{.CsrfTokenHtml}}
 			<input id="label-modal-id" name="id" type="hidden">
-			<div class="ui grid">
-				<div class="three wide column">
-					<div class="ui small input">
-						<input class="new-label-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
-					</div>
+			<div class="required field">
+				<label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
+				<div class="ui small input">
+					<input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
 				</div>
-				<div class="five wide column">
-					<div class="ui small fluid input">
-						<input class="new-label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
-					</div>
+			</div>
+			<div class="field label-exclusive-input-field">
+				<div class="ui checkbox">
+					<input class="label-exclusive-input" name="exclusive" type="checkbox">
+					<label>{{.locale.Tr "repo.issues.label_exclusive"}}</label>
 				</div>
+				<br/>
+				<small class="desc">{{.locale.Tr "repo.issues.label_exclusive_desc" | Safe}}</small>
+				<div class="desc gt-ml-2 gt-mt-3 gt-hidden label-exclusive-warning">
+					{{svg "octicon-alert"}} {{.locale.Tr "repo.issues.label_exclusive_warning" | Safe}}
+				</div>
+			</div>
+			<div class="field">
+				<label for="description">{{.locale.Tr "repo.issues.label_description"}}</label>
+				<div class="ui small fluid input">
+					<input class="label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
+				</div>
+			</div>
+			<div class="field color-field">
+				<label for="color">{{$.locale.Tr "repo.issues.label_color"}}</label>
 				<div class="color picker column">
 					<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
-				</div>
-				<div class="column precolors">
-					{{template "repo/issue/label_precolors"}}
+					<div class="column precolors">
+						{{template "repo/issue/label_precolors"}}
+					</div>
 				</div>
 			</div>
 		</form>
 	</div>
 	<div class="actions">
-		<div class="ui negative button">
+		<div class="ui secondary small basic cancel button">
 			{{.locale.Tr "cancel"}}
 		</div>
-		<div class="ui positive button">
+		<div class="ui primary small approve button">
 			{{.locale.Tr "save"}}
 		</div>
 	</div>
diff --git a/templates/repo/issue/labels/label.tmpl b/templates/repo/issue/labels/label.tmpl
index 0afe5cb6e7..87d8f0c41c 100644
--- a/templates/repo/issue/labels/label.tmpl
+++ b/templates/repo/issue/labels/label.tmpl
@@ -1,9 +1,7 @@
 <a
-	class="ui label item {{if not .label.IsChecked}}hide{{end}}"
+	class="item {{if not .label.IsChecked}}hide{{end}}"
 	id="label_{{.label.ID}}"
 	href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}}
-	style="color: {{.label.ForegroundColor}}; background-color: {{.label.Color}}"
-	title="{{.label.Description | RenderEmojiPlain}}"
 >
-	{{.label.Name | RenderEmoji}}
+	{{RenderLabel .label}}
 </a>
diff --git a/templates/repo/issue/labels/label_list.tmpl b/templates/repo/issue/labels/label_list.tmpl
index 464c9fe208..e8f00fa256 100644
--- a/templates/repo/issue/labels/label_list.tmpl
+++ b/templates/repo/issue/labels/label_list.tmpl
@@ -30,28 +30,24 @@
 		{{range .Labels}}
 			<li class="item">
 			<div class="ui grid middle aligned">
+				<div class="nine wide column">
+					{{RenderLabel .}}
+					{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}
+				</div>
 				<div class="four wide column">
-					<div class="ui label" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag"}} {{.Name | RenderEmoji}}</div>
-				</div>
-				<div class="six wide column">
-					<div class="ui">
-					{{.Description | RenderEmoji}}
-					</div>
-				</div>
-				<div class="three wide column">
 					{{if $.PageIsOrgSettingsLabels}}
-						<a class="ui right open-issues" href="{{AppSubUrl}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
+						<a class="ui left open-issues" href="{{AppSubUrl}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
 					{{else}}
-						<a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
+						<a class="ui left open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenIssues}}</a>
 					{{end}}
 				</div>
 				<div class="three wide column">
 					{{if and (not $.PageIsOrgSettingsLabels ) (not $.Repository.IsArchived) (or $.CanWriteIssues $.CanWritePulls)}}
 						<a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
-						<a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
+						<a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
 					{{else if $.PageIsOrgSettingsLabels}}
 						<a class="ui right delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
-						<a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
+						<a class="ui right edit-label-button" href="#" data-id="{{.ID}}" data-title="{{.Name}}" {{if .Exclusive}}data-exclusive{{end}} data-num-issues="{{.NumIssues}}" data-description="{{.Description}}" data-color={{.Color}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
 					{{end}}
 				</div>
 			</div>
@@ -73,16 +69,12 @@
 					{{range .OrgLabels}}
 					<li class="item">
 					<div class="ui grid middle aligned">
-						<div class="three wide column">
-							<div class="ui label" style="color: {{.ForegroundColor}}; background-color: {{.Color}}">{{svg "octicon-tag"}} {{.Name | RenderEmoji}}</div>
+						<div class="nine wide column">
+							{{RenderLabel .}}
+							{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}
 						</div>
-						<div class="seven wide column">
-							<div class="ui">
-							{{.Description | RenderEmoji}}
-							</div>
-						</div>
-						<div class="three wide column">
-								<a class="ui right open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenRepoIssues}}</a>
+						<div class="four wide column">
+							<a class="ui left open-issues" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{svg "octicon-issue-opened"}} {{$.locale.Tr "repo.issues.label_open_issues" .NumOpenRepoIssues}}</a>
 						</div>
 						<div class="three wide column">
 						</div>
diff --git a/templates/repo/issue/labels/label_new.tmpl b/templates/repo/issue/labels/label_new.tmpl
index 035a4db800..62f7155b74 100644
--- a/templates/repo/issue/labels/label_new.tmpl
+++ b/templates/repo/issue/labels/label_new.tmpl
@@ -1,27 +1,47 @@
-<div class="ui new-label segment hide">
-	<form class="ui form" action="{{$.Link}}/new" method="post">
-		{{.CsrfTokenHtml}}
-		<div class="ui grid">
-			<div class="three wide column">
+<div class="ui small new-label modal">
+	<div class="header">
+		{{.locale.Tr "repo.issues.new_label"}}
+	</div>
+	<div class="content">
+		<form class="ui new-label form ignore-dirty" action="{{$.Link}}/new" method="post">
+			{{.CsrfTokenHtml}}
+			<div class="required field">
+				<label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
 				<div class="ui small input">
-					<input class="new-label-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
+					<input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
 				</div>
 			</div>
-			<div class="three wide column">
+			<div class="field label-exclusive-input-field">
+				<div class="ui checkbox">
+					<input class="label-exclusive-input" name="exclusive" type="checkbox">
+					<label>{{.locale.Tr "repo.issues.label_exclusive"}}</label>
+				</div>
+				<br/>
+				<small class="desc">{{.locale.Tr "repo.issues.label_exclusive_desc" | Safe}}</small>
+			</div>
+			<div class="field">
+				<label for="description">{{.locale.Tr "repo.issues.label_description"}}</label>
 				<div class="ui small fluid input">
-					<input class="new-label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
+					<input class="label-desc-input" name="description" placeholder="{{.locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
 				</div>
 			</div>
-			<div class="color picker column">
-				<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
-			</div>
-			<div class="column precolors">
-				{{template "repo/issue/label_precolors"}}
-			</div>
-			<div class="buttons">
-				<div class="ui secondary small basic cancel button">{{.locale.Tr "repo.milestones.cancel"}}</div>
-				<button class="ui primary small button">{{.locale.Tr "repo.issues.create_label"}}</button>
+			<div class="field color-field">
+				<label for="color">{{$.locale.Tr "repo.issues.label_color"}}</label>
+				<div class="color picker column">
+					<input class="color-picker" name="color" value="#70c24a" required maxlength="7">
+					<div class="column precolors">
+						{{template "repo/issue/label_precolors"}}
+					</div>
+				</div>
 			</div>
+		</form>
+	</div>
+	<div class="actions">
+		<div class="ui secondary small basic cancel button">
+			{{.locale.Tr "cancel"}}
 		</div>
-	</form>
+		<div class="ui primary small approve button">
+			{{.locale.Tr "repo.issues.create_label"}}
+		</div>
+	</div>
 </div>
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index 4b55e7bec8..0e4969a706 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -50,8 +50,14 @@
 							</div>
 							<span class="info">{{.locale.Tr "repo.issues.filter_label_exclude" | Safe}}</span>
 							<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_label_no_select"}}</a>
+							{{$previousExclusiveScope := "_no_scope"}}
 							{{range .Labels}}
-								<a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if .IsSelected}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}</a>
+								{{$exclusiveScope := .ExclusiveScope}}
+								{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+									<div class="ui divider"></div>
+								{{end}}
+								{{$previousExclusiveScope = $exclusiveScope}}
+								<a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if .IsSelected}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel .}}</a>
 							{{end}}
 						</div>
 					</div>
@@ -217,9 +223,15 @@
 							{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 						</span>
 						<div class="menu">
+							{{$previousExclusiveScope := "_no_scope"}}
 							{{range .Labels}}
+								{{$exclusiveScope := .ExclusiveScope}}
+								{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+									<div class="ui divider"></div>
+								{{end}}
+								{{$previousExclusiveScope = $exclusiveScope}}
 								<div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
-									{{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+									{{if contain $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel .}}
 								</div>
 							{{end}}
 						</div>
diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl
index 8d6a97a713..57012bddb6 100644
--- a/templates/repo/issue/milestone_issues.tmpl
+++ b/templates/repo/issue/milestone_issues.tmpl
@@ -58,7 +58,7 @@
 							<span class="info">{{.locale.Tr "repo.issues.filter_label_exclude" | Safe}}</span>
 							<a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_label_no_select"}}</a>
 							{{range .Labels}}
-								<a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}</a>
+								<a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel .}}</a>
 							{{end}}
 						</div>
 					</div>
@@ -161,7 +161,7 @@
 						<div class="menu">
 							{{range .Labels}}
 								<div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
-									{{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}}<span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+									{{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel .}}
 								</div>
 							{{end}}
 						</div>
diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl
index 8fbd9d256a..2a6fcaa995 100644
--- a/templates/repo/issue/new_form.tmpl
+++ b/templates/repo/issue/new_form.tmpl
@@ -53,14 +53,26 @@
 					{{end}}
 					<div class="no-select item">{{.locale.Tr "repo.issues.new.clear_labels"}}</div>
 					{{if or .Labels .OrgLabels}}
+						{{$previousExclusiveScope := "_no_scope"}}
 						{{range .Labels}}
-							<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+							{{$exclusiveScope := .ExclusiveScope}}
+							{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+								<div class="ui divider"></div>
+							{{end}}
+							{{$previousExclusiveScope = $exclusiveScope}}
+							<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}}
 							{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
 						{{end}}
 
 						<div class="ui divider"></div>
+						{{$previousExclusiveScope := "_no_scope"}}
 						{{range .OrgLabels}}
-							<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+							{{$exclusiveScope := .ExclusiveScope}}
+							{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+								<div class="ui divider"></div>
+							{{end}}
+							{{$previousExclusiveScope = $exclusiveScope}}
+							<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}}
 							{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
 						{{end}}
 					{{else}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl
index 9ba46f3715..8cd34ede6e 100644
--- a/templates/repo/issue/view_content/sidebar.tmpl
+++ b/templates/repo/issue/view_content/sidebar.tmpl
@@ -123,13 +123,25 @@
 				{{end}}
 				<div class="no-select item">{{.locale.Tr "repo.issues.new.clear_labels"}}</div>
 				{{if or .Labels .OrgLabels}}
+					{{$previousExclusiveScope := "_no_scope"}}
 					{{range .Labels}}
-						<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+						{{$exclusiveScope := .ExclusiveScope}}
+						{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+							<div class="ui divider"></div>
+						{{end}}
+						{{$previousExclusiveScope = $exclusiveScope}}
+						<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}}
 						{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
 					{{end}}
 					<div class="ui divider"></div>
+					{{$previousExclusiveScope := "_no_scope"}}
 					{{range .OrgLabels}}
-						<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{svg "octicon-check"}}</span><span class="label color" style="background-color: {{.Color}}"></span> {{.Name | RenderEmoji}}
+						{{$exclusiveScope := .ExclusiveScope}}
+						{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+							<div class="ui divider"></div>
+						{{end}}
+						{{$previousExclusiveScope = $exclusiveScope}}
+						<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel .}}
 						{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji}}</small>{{end}}</a>
 					{{end}}
 				{{else}}
diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl
index de0911e6cd..a4ada87353 100644
--- a/templates/repo/projects/view.tmpl
+++ b/templates/repo/projects/view.tmpl
@@ -245,7 +245,7 @@
 						{{if or .Labels .Assignees}}
 						<div class="extra content labels-list gt-p-0 gt-pt-2">
 							{{range .Labels}}
-								<a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a>
+								<a target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel .}}</a>
 							{{end}}
 							<div class="right floated">
 								{{range .Assignees}}
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl
index a246b70093..a43047c79d 100644
--- a/templates/shared/issuelist.tmpl
+++ b/templates/shared/issuelist.tmpl
@@ -42,7 +42,7 @@
 					{{end}}
 					<span class="labels-list gt-ml-2">
 						{{range .Labels}}
-							<a class="ui label" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}}" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a>
+							<a href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{RenderLabel .}}</a>
 						{{end}}
 					</span>
 				</div>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 00fc3b60c4..2a675766ab 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -15348,6 +15348,11 @@
           "type": "string",
           "x-go-name": "Description"
         },
+        "exclusive": {
+          "type": "boolean",
+          "x-go-name": "Exclusive",
+          "example": false
+        },
         "name": {
           "type": "string",
           "x-go-name": "Name"
@@ -16283,12 +16288,18 @@
       "properties": {
         "color": {
           "type": "string",
-          "x-go-name": "Color"
+          "x-go-name": "Color",
+          "example": "#00aabb"
         },
         "description": {
           "type": "string",
           "x-go-name": "Description"
         },
+        "exclusive": {
+          "type": "boolean",
+          "x-go-name": "Exclusive",
+          "example": false
+        },
         "name": {
           "type": "string",
           "x-go-name": "Name"
@@ -17615,6 +17626,11 @@
           "type": "string",
           "x-go-name": "Description"
         },
+        "exclusive": {
+          "type": "boolean",
+          "x-go-name": "Exclusive",
+          "example": false
+        },
         "id": {
           "type": "integer",
           "format": "int64",
diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go
index 2f27978a37..4344c15ea4 100644
--- a/tests/integration/api_issue_test.go
+++ b/tests/integration/api_issue_test.go
@@ -174,7 +174,7 @@ func TestAPISearchIssues(t *testing.T) {
 	token := getUserToken(t, "user2")
 
 	// as this API was used in the frontend, it uses UI page size
-	expectedIssueCount := 15 // from the fixtures
+	expectedIssueCount := 16 // from the fixtures
 	if expectedIssueCount > setting.UI.IssuePagingNum {
 		expectedIssueCount = setting.UI.IssuePagingNum
 	}
@@ -198,7 +198,7 @@ func TestAPISearchIssues(t *testing.T) {
 	req = NewRequest(t, "GET", link.String())
 	resp = MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.Len(t, apiIssues, 8)
+	assert.Len(t, apiIssues, 9)
 	query.Del("since")
 	query.Del("before")
 
@@ -214,15 +214,15 @@ func TestAPISearchIssues(t *testing.T) {
 	req = NewRequest(t, "GET", link.String())
 	resp = MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
-	assert.Len(t, apiIssues, 17)
+	assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
+	assert.Len(t, apiIssues, 18)
 
 	query.Add("limit", "10")
 	link.RawQuery = query.Encode()
 	req = NewRequest(t, "GET", link.String())
 	resp = MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
+	assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
 	assert.Len(t, apiIssues, 10)
 
 	query = url.Values{"assigned": {"true"}, "state": {"all"}, "token": {token}}
@@ -251,7 +251,7 @@ func TestAPISearchIssues(t *testing.T) {
 	req = NewRequest(t, "GET", link.String())
 	resp = MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.Len(t, apiIssues, 6)
+	assert.Len(t, apiIssues, 7)
 
 	query = url.Values{"owner": {"user3"}, "token": {token}} // organization
 	link.RawQuery = query.Encode()
@@ -272,7 +272,7 @@ func TestAPISearchIssuesWithLabels(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 
 	// as this API was used in the frontend, it uses UI page size
-	expectedIssueCount := 15 // from the fixtures
+	expectedIssueCount := 16 // from the fixtures
 	if expectedIssueCount > setting.UI.IssuePagingNum {
 		expectedIssueCount = setting.UI.IssuePagingNum
 	}
diff --git a/tests/integration/api_nodeinfo_test.go b/tests/integration/api_nodeinfo_test.go
index 6e80ebc19c..29fff8ba72 100644
--- a/tests/integration/api_nodeinfo_test.go
+++ b/tests/integration/api_nodeinfo_test.go
@@ -34,7 +34,7 @@ func TestNodeinfo(t *testing.T) {
 		assert.True(t, nodeinfo.OpenRegistrations)
 		assert.Equal(t, "gitea", nodeinfo.Software.Name)
 		assert.Equal(t, 24, nodeinfo.Usage.Users.Total)
-		assert.Equal(t, 17, nodeinfo.Usage.LocalPosts)
+		assert.Equal(t, 18, nodeinfo.Usage.LocalPosts)
 		assert.Equal(t, 2, nodeinfo.Usage.LocalComments)
 	})
 }
diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go
index c913a2000c..eccf3c3795 100644
--- a/tests/integration/issue_test.go
+++ b/tests/integration/issue_test.go
@@ -356,7 +356,7 @@ func TestSearchIssues(t *testing.T) {
 
 	session := loginUser(t, "user2")
 
-	expectedIssueCount := 15 // from the fixtures
+	expectedIssueCount := 16 // from the fixtures
 	if expectedIssueCount > setting.UI.IssuePagingNum {
 		expectedIssueCount = setting.UI.IssuePagingNum
 	}
@@ -377,7 +377,7 @@ func TestSearchIssues(t *testing.T) {
 	req = NewRequest(t, "GET", link.String())
 	resp = session.MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.Len(t, apiIssues, 8)
+	assert.Len(t, apiIssues, 9)
 	query.Del("since")
 	query.Del("before")
 
@@ -393,15 +393,15 @@ func TestSearchIssues(t *testing.T) {
 	req = NewRequest(t, "GET", link.String())
 	resp = session.MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
-	assert.Len(t, apiIssues, 17)
+	assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
+	assert.Len(t, apiIssues, 18)
 
 	query.Add("limit", "5")
 	link.RawQuery = query.Encode()
 	req = NewRequest(t, "GET", link.String())
 	resp = session.MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.EqualValues(t, "17", resp.Header().Get("X-Total-Count"))
+	assert.EqualValues(t, "18", resp.Header().Get("X-Total-Count"))
 	assert.Len(t, apiIssues, 5)
 
 	query = url.Values{"assigned": {"true"}, "state": {"all"}}
@@ -430,7 +430,7 @@ func TestSearchIssues(t *testing.T) {
 	req = NewRequest(t, "GET", link.String())
 	resp = session.MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.Len(t, apiIssues, 6)
+	assert.Len(t, apiIssues, 7)
 
 	query = url.Values{"owner": {"user3"}} // organization
 	link.RawQuery = query.Encode()
@@ -450,7 +450,7 @@ func TestSearchIssues(t *testing.T) {
 func TestSearchIssuesWithLabels(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 
-	expectedIssueCount := 15 // from the fixtures
+	expectedIssueCount := 16 // from the fixtures
 	if expectedIssueCount > setting.UI.IssuePagingNum {
 		expectedIssueCount = setting.UI.IssuePagingNum
 	}
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index 07c73ff5cf..3244034782 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -26,25 +26,10 @@
 <script>
 import $ from 'jquery';
 import {SvgIcon} from '../svg.js';
+import {useLightTextOnBackground} from '../utils.js';
 
 const {appSubUrl, i18n} = window.config;
 
-// NOTE: see models/issue_label.go for similar implementation
-const srgbToLinear = (color) => {
-  color /= 255;
-  if (color <= 0.04045) {
-    return color / 12.92;
-  }
-  return ((color + 0.055) / 1.055) ** 2.4;
-};
-const luminance = (colorString) => {
-  const r = srgbToLinear(parseInt(colorString.substring(0, 2), 16));
-  const g = srgbToLinear(parseInt(colorString.substring(2, 4), 16));
-  const b = srgbToLinear(parseInt(colorString.substring(4, 6), 16));
-  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
-};
-const luminanceThreshold = 0.179;
-
 export default {
   components: {SvgIcon},
   data: () => ({
@@ -92,10 +77,10 @@ export default {
     labels() {
       return this.issue.labels.map((label) => {
         let textColor;
-        if (luminance(label.color) < luminanceThreshold) {
-          textColor = '#ffffff';
+        if (useLightTextOnBackground(label.color)) {
+          textColor = '#eeeeee';
         } else {
-          textColor = '#000000';
+          textColor = '#111111';
         }
         return {name: label.name, color: `#${label.color}`, textColor};
       });
diff --git a/web_src/js/features/common-issue.js b/web_src/js/features/common-issue.js
index 4a62089c60..f53dd5081b 100644
--- a/web_src/js/features/common-issue.js
+++ b/web_src/js/features/common-issue.js
@@ -32,7 +32,7 @@ export function initCommonIssue() {
     syncIssueSelectionState();
   });
 
-  $('.issue-action').on('click', async function () {
+  $('.issue-action').on('click', async function (e) {
     let action = this.getAttribute('data-action');
     let elementId = this.getAttribute('data-element-id');
     const url = this.getAttribute('data-url');
@@ -43,6 +43,9 @@ export function initCommonIssue() {
       elementId = '';
       action = 'clear';
     }
+    if (action === 'toggle' && e.altKey) {
+      action = 'toggle-alt';
+    }
     updateIssuesMeta(
       url,
       action,
diff --git a/web_src/js/features/comp/LabelEdit.js b/web_src/js/features/comp/LabelEdit.js
index df294078fa..313d406821 100644
--- a/web_src/js/features/comp/LabelEdit.js
+++ b/web_src/js/features/comp/LabelEdit.js
@@ -1,26 +1,64 @@
 import $ from 'jquery';
 import {initCompColorPicker} from './ColorPicker.js';
 
+function isExclusiveScopeName(name) {
+  return /.*[^/]\/[^/].*/.test(name);
+}
+
+function updateExclusiveLabelEdit(form) {
+  const nameInput = $(`${form} .label-name-input`);
+  const exclusiveField = $(`${form} .label-exclusive-input-field`);
+  const exclusiveCheckbox = $(`${form} .label-exclusive-input`);
+  const exclusiveWarning = $(`${form} .label-exclusive-warning`);
+
+  if (isExclusiveScopeName(nameInput.val())) {
+    exclusiveField.removeClass('muted');
+    if (exclusiveCheckbox.prop('checked') && exclusiveCheckbox.data('exclusive-warn')) {
+      exclusiveWarning.removeClass('gt-hidden');
+    } else {
+      exclusiveWarning.addClass('gt-hidden');
+    }
+  } else {
+    exclusiveField.addClass('muted');
+    exclusiveWarning.addClass('gt-hidden');
+  }
+}
+
 export function initCompLabelEdit(selector) {
   if (!$(selector).length) return;
-  // Create label
-  const $newLabelPanel = $('.new-label.segment');
-  $('.new-label.button').on('click', () => {
-    $newLabelPanel.show();
-  });
-  $('.new-label.segment .cancel').on('click', () => {
-    $newLabelPanel.hide();
-  });
-
   initCompColorPicker();
 
+  // Create label
+  $('.new-label.button').on('click', () => {
+    updateExclusiveLabelEdit('.new-label');
+    $('.new-label.modal').modal({
+      onApprove() {
+        $('.new-label.form').trigger('submit');
+      }
+    }).modal('show');
+    return false;
+  });
+
+  // Edit label
   $('.edit-label-button').on('click', function () {
     $('.edit-label .color-picker').minicolors('value', $(this).data('color'));
     $('#label-modal-id').val($(this).data('id'));
-    $('.edit-label .new-label-input').val($(this).data('title'));
-    $('.edit-label .new-label-desc-input').val($(this).data('description'));
+
+    const nameInput = $('.edit-label .label-name-input');
+    nameInput.val($(this).data('title'));
+
+    const exclusiveCheckbox = $('.edit-label .label-exclusive-input');
+    exclusiveCheckbox.prop('checked', this.hasAttribute('data-exclusive'));
+    // Warn when label was previously not exclusive and used in issues
+    exclusiveCheckbox.data('exclusive-warn',
+      $(this).data('num-issues') > 0 &&
+      (!this.hasAttribute('data-exclusive') || !isExclusiveScopeName(nameInput.val())));
+    updateExclusiveLabelEdit('.edit-label');
+
+    $('.edit-label .label-desc-input').val($(this).data('description'));
     $('.edit-label .color-picker').val($(this).data('color'));
     $('.edit-label .minicolors-swatch-color').css('background-color', $(this).data('color'));
+
     $('.edit-label.modal').modal({
       onApprove() {
         $('.edit-label.form').trigger('submit');
@@ -28,4 +66,17 @@ export function initCompLabelEdit(selector) {
     }).modal('show');
     return false;
   });
+
+  $('.new-label .label-name-input').on('input', () => {
+    updateExclusiveLabelEdit('.new-label');
+  });
+  $('.new-label .label-exclusive-input').on('change', () => {
+    updateExclusiveLabelEdit('.new-label');
+  });
+  $('.edit-label .label-name-input').on('input', () => {
+    updateExclusiveLabelEdit('.edit-label');
+  });
+  $('.edit-label .label-exclusive-input').on('change', () => {
+    updateExclusiveLabelEdit('.edit-label');
+  });
 }
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
index 07c67ba5da..2cf4963b6a 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.js
@@ -110,35 +110,59 @@ export function initRepoCommentForm() {
       }
 
       hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
-      if ($(this).hasClass('checked')) {
-        $(this).removeClass('checked');
-        $(this).find('.octicon-check').addClass('invisible');
-        if (hasUpdateAction) {
-          if (!($(this).data('id') in items)) {
-            items[$(this).data('id')] = {
-              'update-url': $listMenu.data('update-url'),
-              action: 'detach',
-              'issue-id': $listMenu.data('issue-id'),
-            };
-          } else {
-            delete items[$(this).data('id')];
+
+      const clickedItem = $(this);
+      const scope = $(this).attr('data-scope');
+      const canRemoveScope = e.altKey;
+
+      $(this).parent().find('.item').each(function () {
+        if (scope) {
+          // Enable only clicked item for scoped labels
+          if ($(this).attr('data-scope') !== scope) {
+            return true;
+          }
+          if ($(this).is(clickedItem)) {
+            if (!canRemoveScope && $(this).hasClass('checked')) {
+              return true;
+            }
+          } else if (!$(this).hasClass('checked')) {
+            return true;
+          }
+        } else if (!$(this).is(clickedItem)) {
+          // Toggle for other labels
+          return true;
+        }
+
+        if ($(this).hasClass('checked')) {
+          $(this).removeClass('checked');
+          $(this).find('.octicon-check').addClass('invisible');
+          if (hasUpdateAction) {
+            if (!($(this).data('id') in items)) {
+              items[$(this).data('id')] = {
+                'update-url': $listMenu.data('update-url'),
+                action: 'detach',
+                'issue-id': $listMenu.data('issue-id'),
+              };
+            } else {
+              delete items[$(this).data('id')];
+            }
+          }
+        } else {
+          $(this).addClass('checked');
+          $(this).find('.octicon-check').removeClass('invisible');
+          if (hasUpdateAction) {
+            if (!($(this).data('id') in items)) {
+              items[$(this).data('id')] = {
+                'update-url': $listMenu.data('update-url'),
+                action: 'attach',
+                'issue-id': $listMenu.data('issue-id'),
+              };
+            } else {
+              delete items[$(this).data('id')];
+            }
           }
         }
-      } else {
-        $(this).addClass('checked');
-        $(this).find('.octicon-check').removeClass('invisible');
-        if (hasUpdateAction) {
-          if (!($(this).data('id') in items)) {
-            items[$(this).data('id')] = {
-              'update-url': $listMenu.data('update-url'),
-              action: 'attach',
-              'issue-id': $listMenu.data('issue-id'),
-            };
-          } else {
-            delete items[$(this).data('id')];
-          }
-        }
-      }
+      });
 
       // TODO: Which thing should be done for choosing review requests
       // to make chosen items be shown on time here?
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index f6d6c89816..534f517853 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -1,4 +1,5 @@
 import $ from 'jquery';
+import {useLightTextOnBackground} from '../utils.js';
 
 const {csrfToken} = window.config;
 
@@ -183,26 +184,13 @@ export function initRepoProject() {
 }
 
 function setLabelColor(label, color) {
-  const red = getRelativeColor(parseInt(color.slice(1, 3), 16));
-  const green = getRelativeColor(parseInt(color.slice(3, 5), 16));
-  const blue = getRelativeColor(parseInt(color.slice(5, 7), 16));
-  const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
-
-  if (luminance > 0.179) {
-    label.removeClass('light-label').addClass('dark-label');
-  } else {
+  if (useLightTextOnBackground(color)) {
     label.removeClass('dark-label').addClass('light-label');
+  } else {
+    label.removeClass('light-label').addClass('dark-label');
   }
 }
 
-/**
- * Inspired by W3C recommendation https://www.w3.org/TR/WCAG20/#relativeluminancedef
- */
-function getRelativeColor(color) {
-  color /= 255;
-  return color <= 0.03928 ? color / 12.92 : ((color + 0.055) / 1.055) ** 2.4;
-}
-
 function rgbToHex(rgb) {
   rgb = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+).*\)$/);
   return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
diff --git a/web_src/js/utils.js b/web_src/js/utils.js
index c7624404c7..b3ffbf2988 100644
--- a/web_src/js/utils.js
+++ b/web_src/js/utils.js
@@ -146,3 +146,18 @@ export function toAbsoluteUrl(url) {
   }
   return `${window.location.origin}${url}`;
 }
+
+// determine if light or dark text color should be used on a given background color
+// NOTE: see models/issue_label.go for similar implementation
+export function useLightTextOnBackground(backgroundColor) {
+  if (backgroundColor[0] === '#') {
+    backgroundColor = backgroundColor.substring(1);
+  }
+  // Perceived brightness from: https://www.w3.org/TR/AERT/#color-contrast
+  // In the future WCAG 3 APCA may be a better solution.
+  const r = parseInt(backgroundColor.substring(0, 2), 16);
+  const g = parseInt(backgroundColor.substring(2, 4), 16);
+  const b = parseInt(backgroundColor.substring(4, 6), 16);
+  const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+  return brightness < 0.35;
+}
diff --git a/web_src/less/_base.less b/web_src/less/_base.less
index 4b65ae6812..771049ad39 100644
--- a/web_src/less/_base.less
+++ b/web_src/less/_base.less
@@ -1116,6 +1116,7 @@ a.ui.card:hover,
 
 .ui.modal > .content {
   background: var(--color-body);
+  text-align: left !important;
 }
 
 .ui.modal > .actions {
@@ -1364,6 +1365,10 @@ a.ui.card:hover,
   -webkit-text-fill-color: var(--color-black) !important;
 }
 
+.ui.form .field.muted {
+  opacity: var(--opacity-disabled);
+}
+
 .ui.loading.loading.input > i.icon svg {
   visibility: hidden;
 }
@@ -2568,8 +2573,7 @@ table th[data-sortt-desc] {
     border-top: none;
 
     a {
-      font-size: 15px;
-      padding-top: 5px;
+      font-size: 12px;
       padding-right: 10px;
       color: var(--color-text-light);
 
@@ -2581,10 +2585,6 @@ table th[data-sortt-desc] {
         margin-right: 30px;
       }
     }
-
-    .ui.label {
-      font-size: 1em;
-    }
   }
 
   .item:last-child {
diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less
index f7087d4d30..b2c4cdcdfb 100644
--- a/web_src/less/_repository.less
+++ b/web_src/less/_repository.less
@@ -92,7 +92,7 @@
   .metas {
     .menu {
       overflow-x: auto;
-      max-height: 300px;
+      max-height: 500px;
     }
 
     .ui.list {
@@ -155,12 +155,6 @@
   }
 
   .filter.menu {
-    .label.color {
-      border-radius: 3px;
-      margin-left: 15px;
-      padding: 0 8px;
-    }
-
     &.labels {
       .label-filter .menu .info {
         display: inline-block;
@@ -181,7 +175,7 @@
     }
 
     .menu {
-      max-height: 300px;
+      max-height: 500px;
       overflow-x: auto;
       right: 0 !important;
       left: auto !important;
@@ -190,7 +184,7 @@
 
   .select-label {
     .desc {
-      padding-left: 16px;
+      padding-left: 23px;
     }
   }
 
@@ -607,7 +601,7 @@
         min-width: 220px;
 
         .filter.menu {
-          max-height: 300px;
+          max-height: 500px;
           overflow-x: auto;
         }
       }
@@ -2774,7 +2768,7 @@
 }
 
 .edit-label.modal,
-.new-label.segment {
+.new-label.modal {
   .form {
     .column {
       padding-right: 0;
@@ -2786,12 +2780,9 @@
     }
 
     .color.picker.column {
-      width: auto;
-
-      .color-picker {
-        height: 35px;
-        width: auto;
-        padding-left: 30px;
+      display: flex;
+      .minicolors {
+        flex: 1;
       }
     }
 
@@ -2872,6 +2863,35 @@
   line-height: 1.3em; // there is a `font-size: 1.25em` for inside emoji, so here the line-height needs to be larger slightly
 }
 
+// Scoped labels with different colors on left and right, and slanted divider in the middle
+.scope-parent {
+  background: none !important;
+  padding: 0 !important;
+}
+
+.ui.label.scope-left {
+  border-bottom-right-radius: 0;
+  border-top-right-radius: 0;
+  padding-right: 0;
+  margin-right: 0;
+}
+
+.ui.label.scope-middle {
+  width: 12px;
+  border-radius: 0;
+  padding-left: 0;
+  padding-right: 0;
+  margin-left: 0;
+  margin-right: 0;
+}
+
+.ui.label.scope-right {
+  border-bottom-left-radius: 0;
+  border-top-left-radius: 0;
+  padding-left: 0;
+  margin-left: 0;
+}
+
 .repo-button-row {
   margin-bottom: 10px;
 }