UI Label Colors
Let’s create a simple label color UI for an issue tracker.
The labels are styled with a tint color based on the hue, and the color picker
is a simple <RangeSlider />
with a float label to show the hue value.
All of this is achievable in any JS framework (or vanilla), but I’m demonstrating it with Svelte here as it’s the simplest framework to work with.
LabelColors.svelte
The general UI layout for this recipe. We show a grid of labels with
<input>
fields for the label name and a <button>
to open the color picker.
Both the <input>
and <RangeSlider />
are bound to the same hue variable,
and the <RangeSlider />
simply allows a min
and max
of the hue property (0-360
).
<script>
let labels = [
{ name: 'Bug', hue: 20, isOpen: false },
{ name: 'Feature', hue: 140, isOpen: false },
{ name: 'Documentation', hue: 202, isOpen: true },
{ name: 'Maintenance', hue: 230, isOpen: false },
{ name: 'Question', hue: 320, isOpen: false },
{ name: 'Invalid', hue: 0, isOpen: false },
];
const openPicker = (index) => labels[index].isOpen = true;
const closePicker = (index) => labels[index].isOpen = false;
</script>
<ul class="labels" data-grid>
{#each labels as label,i}
<li class="label" data-subgrid style="--hue: {label.hue}">
<input class="label-name" type="text" bind:value={label.name} />
<button class="label-color" type="button"
on:click={() => openPicker(i)}
use:clickOutside={() => closePicker(i)}
>
<Icon icon="tabler:square-rounded-filled" />
<LabelColorSlider bind:hue={label.hue} bind:isOpen={label.isOpen} />
</button>
</li>
{/each}
</ul>
/* remove the default list styling */
.labels {
list-style: none;
padding: 0;
margin: 0;
}
/* set the labels in a grid */
[data-grid] {
display: grid;
grid-template-columns: 200px 1fr;
gap: 0;
}
/* each label has a subgrid to show the name and color picker */
[data-subgrid] {
display: grid;
grid-column: span 2;
grid-template-columns: subgrid;
gap: 0.5rem;
}
/* each label has a tint color based on the hue from the labels object */
.label {
--tint: hsl(var(--hue), 65%, 60%);
--tint2: hsl(calc(var(--hue) + 22), 65%, 60%);
/* position relative so the popover stays within this grid cell on mobile */
position: relative;
}
/* style the input field */
.label-name {
font-weight: 500;
color: var(--tint);
padding: 0.5em 1em;
width: 100%;
}
/* style the color picker button */
.label-color {
padding: 0.5em;
width: max-content;
color: var(--tint);
}
/* make the button the positioned ancestor for the popover (on desktop) */
@media (min-width: 768px) {
.label-color {
position: relative;
}
}
LabelColorSlider.svelte
This is a simple sub-component which displays a nice little popover
with a <RangeSlider />
inside.
<script>
export let hue = 0;
export let isOpen = false;
</script>
<div class="label-slider-container" class:isOpen>
<RangeSlider
class="label-slider"
bind:value={hue}
pips
all={false}
rest="pips"
pipstep={20}
min={0}
max={320}
float
handleFormatter={() => `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2q-.327 0-.642.005l-.616.017l-.299.013l-.579.034l-.553.046c-4.785.464-6.732 2.411-7.196 7.196l-.046.553l-.034.579q-.008.147-.013.299l-.017.616l-.004.318L2 12q0 .327.005.642l.017.616l.013.299l.034.579l.046.553c.464 4.785 2.411 6.732 7.196 7.196l.553.046l.579.034q.147.008.299.013l.616.017L12 22l.642-.005l.616-.017l.299-.013l.579-.034l.553-.046c4.785-.464 6.732-2.411 7.196-7.196l.046-.553l.034-.579q.008-.147.013-.299l.017-.616L22 12l-.005-.642l-.017-.616l-.013-.299l-.034-.579l-.046-.553c-.464-4.785-2.411-6.732-7.196-7.196l-.553-.046l-.579-.034l-.299-.013l-.616-.017l-.318-.004z"/></svg>
`}
/>
</div>
/* setup the popover container for the slider */
.label-slider-container {
opacity: 0;
display: none;
transition: all 0.25s ease-in allow-discrete;
}
@starting-style {
.label-slider-container {
opacity: 0;
}
}
.label-slider-container.isOpen {
opacity: 1;
display: block;
transition: all 0.25s ease-out allow-discrete;
}
/* position and style the popover container */
.label-slider-container {
position: absolute;
bottom: -5px;
left: 0;
translate: 0 100%;
z-index: 1;
width: 350px;
height: 36px;
background: white;
place-content: center;
border-radius: 14px;
box-shadow:
rgba(50, 50, 93, 0.2) 0px 2px 40px -2px,
rgba(50, 50, 93, 0.25) 0px 8px 27px -5px,
rgba(0, 0, 0, 0.3) 0px 4px 16px -8px;
/* add a triangle to the top of the popover (mobile) */
&:before {
content: '';
position: absolute;
top: 0;
left: 20px;
transform: translateY(-50%) translateX(-50%) rotate(45deg);
width: 0.75rem;
height: 0.75rem;
background: white;
border-radius: 3px;
box-shadow: none;
}
}
/* style the popover container for desktop
(move to the right of button) */
@media (min-width: 768px) {
.label-slider-container {
top: 0;
bottom: auto;
left: calc(100% + 1em);
translate: 0 0;
border-radius: 8px 14px 14px 8px;
/* position the triangle to the left of the popover for desktop */
&:before {
top: 50%;
left: 0;
transform: translateY(-50%) translateX(-50%) rotate(45deg);
}
}
}
/* style the range slider */
.label-slider-container .label-slider.rangeSlider {
/* set the colors for the slider */
--slider-base: white;
--slider-fg: white;
--range-float-text: var(--hue);
--range-float: white;
--range-float-inactive: white;
margin: 0 auto;
width: 330px;
height: 18px;
border-radius: 7px;
/* use a 'longer' hue gradient for the slider to show the rainbow */
background: linear-gradient(
to right in oklch longer hue,
hsl(0, 65%, 60%),
hsl(320, 65%, 60%)
);
/* move the pips container inside of the slider */
& .rangePips {
position: relative;
height: 100%;
top: 0;
left: -1px;
}
/* make each pip half the height of the slider */
& .rsPip {
height: 50%;
top: 25%;
}
/* hide the nub (the little circle that moves) */
& .rangeNub {
opacity: 0;
}
/* change the handle to be a vertical line */
& .rangeHandle {
height: 100%;
background: rgba(255, 255, 255, 0.726);
width: 1px;
top: 50%;
&:before {
/* remove the hover effect */
content: none;
}
}
/* style the float label, and make sure it's always visible */
& .rangeFloat {
font-size: 1rem;
padding: 0.1em 0.25em 0.2em;
bottom: auto;
top: 120%;
border-radius: 0.5em;
box-shadow:
rgba(50, 50, 93, 0.25) 0px -4px 25px -3px,
rgba(0, 0, 0, 0.3) 0px 2px 16px -4px;
display: flex;
align-items: center;
opacity: 1;
translate: -50% 0;
pointer-events: all;
/* add a triangle to the top of the float label */
&:before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateY(-50%) translateX(-50%) rotate(45deg);
width: 0.75rem;
height: 0.75rem;
background: white;
border-radius: 3px;
box-shadow: none;
mask-image: linear-gradient(to top left, transparent 50%, black 50%);
}
}
}