Skip to content
Bladewell

<x-widget.color-picker>

Colour picker Blade component for Laravel and Livewire

A colour in a field: a hex box that submits #rrggbb, the browser's own picker beside it for any colour, and optional swatches as a radio group, named for screen readers. A preview shows the colour with black or white text, whichever reads better. Typing #abc or #ABCDEF tidies to #aabbcc, and anything that isn't a colour goes back to the last one. Works with Livewire wire:model.

php artisan bladewell:add color-picker

Usage

Livewire

In a Livewire component, bind with wire:model (deferred) or wire:model.live to a string property: public string $accent = '#7c3aed'. No name is needed. A swatch, the browser's picker or a typed colour updates it, and setting it in PHP selects the matching swatch and repaints the preview after the render.

Blade
<x-widget.color-picker label="Accent colour" wire:model.live="accent" :swatches="['#dc2626', '#16a34a', '#2563eb']" />

Examples

Basic

A hex box that submits #rrggbb, with the browser's own picker beside it. Typing #ABC tidies to #aabbcc when you leave the box; text that isn't a colour goes back to the last one. Validate with 'hex_color'.

Show code
Blade
<x-widget.color-picker name="accent" label="Accent colour" value="#7c3aed" class="max-w-xs" />

Swatches

swatches offers colours to pick in one click, as a radio group: the arrow keys move between them, and a screen reader hears each one's name. Give them names (name => hex) or a plain list of hex values. custom="false" leaves out the browser's own picker, so only the box and the swatches choose.

Show code
Blade
<x-widget.color-picker name="label_color" label="Label colour" value="#16a34a" :custom="false" :swatches="[
    'Red' => '#dc2626',
    'Amber' => '#d97706',
    'Green' => '#16a34a',
    'Teal' => '#0d9488',
    'Blue' => '#2563eb',
    'Violet' => '#7c3aed',
    'Slate' => '#475569',
]" class="max-w-xs" />

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.color-picker>

Prop Default Description
name null What the colour submits as (#rrggbb); its error and old input are found under it. Optional with wire:model, which then names it.
id null Defaults to one made from the name (or the wire:model property).
label null Shown above the field, and its name for screen readers.
value null The colour, as #rrggbb or #rgb. Old input wins after a failed submit; with wire:model and no value, the bound property.
swatches [] Colours to pick from in one click: a list of hex values, or name => hex ("Brand" => "#7c3aed"), whose names screen readers hear.
custom true Offers the browser's own picker beside the box, for any colour. false for the swatches and the box only.
error null An error message of your own; otherwise the validation error for the name, from the session or Livewire.
info null A hint under the field.
bag 'default' Which error bag to read the error from.
disabled false Greyed out: it can't be changed.

Accessibility

All 2 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add color-picker writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from Field, and the theme and base CSS.

resources/views/components/widget/color-picker/index.blade.php Show
index.blade.php
@props([
    // What the colour submits as (#rrggbb); its error and old input are found under it. Optional with wire:model, which
    // then names it.
    'name' => null,
    // Defaults to one made from the name (or the wire:model property).
    'id' => null,
    // Shown above the field, and its name for screen readers.
    'label' => null,
    // The colour, as #rrggbb or #rgb. Old input wins after a failed submit; with wire:model and no value, the bound
    // property.
    'value' => null,
    // Colours to pick from in one click: a list of hex values, or name => hex ("Brand" => "#7c3aed"), whose names screen
    // readers hear.
    'swatches' => [],
    // Offers the browser's own picker beside the box, for any colour. false for the swatches and the box only.
    'custom' => true,
    // An error message of your own; otherwise the validation error for the name, from the session or Livewire.
    'error' => null,
    // A hint under the field.
    'info' => null,
    // Which error bag to read the error from.
    'bag' => 'default',
    // Greyed out: it can't be changed.
    'disabled' => false,
])

@php
    $field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'color', attributes: $attributes);
    // #abc and #ABCDEF read as #aabbcc and #abcdef; anything else isn't a colour this takes.
    $hex = static function (mixed $raw): ?string {
        if (! is_string($raw) || preg_match('/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($raw), $parts) !== 1) {
            return null;
        }
        $digits = strtolower($parts[1]);

        return '#'.(strlen($digits) === 3 ? preg_replace('/(.)/', '$1$1', $digits) : $digits);
    };
    $current = $hex($field->old($value)) ?? '';
    $swatches = collect($swatches)
        ->mapWithKeys(fn (mixed $color, int|string $key): array => [is_int($key) ? (string) $hex($color) : $key => $hex($color)])
        ->filter()
        ->map(fn (string $color, string $name): array => ['color' => $color, 'name' => $name === $color ? strtoupper($color) : $name]);
@endphp

{{--
    The hex box submits, and wire:model binds it. Beside it, the browser's own colour input for any colour; under it, the
    swatches as a radio group (arrow keys, named for screen readers), outside any form so they never submit. Colours are
    painted by resources/js/widget/color-picker through a CSS variable, as no style attribute may be in the markup.
--}}
<x-widget.field :required="$attributes->has('required')" :id="$field->id" :label="$label" :error="$field->errors" :info="$info" :disabled="$disabled" :class="$attributes->get('class')">
    <div data-color-picker class="flex h-full w-full items-center gap-3 ps-3 pe-3">
        @if ($custom)
            <input type="color" data-color-native value="{{ $current ?: '#000000' }}" aria-label="{{ trim(($label ?? 'Colour').' — any colour') }}" @disabled($disabled) class="size-8 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0 disabled:cursor-not-allowed [&::-moz-color-swatch]:rounded-lg [&::-moz-color-swatch]:border-0 [&::-webkit-color-swatch]:rounded-lg [&::-webkit-color-swatch]:border-0 [&::-webkit-color-swatch-wrapper]:p-0">
        @endif
        <input
            type="text"
            id="{{ $field->id }}"
            data-color-hex
            @if ($name) name="{{ $name }}" @endif
            value="{{ $current }}"
            placeholder="#rrggbb"
            inputmode="text"
            autocomplete="off"
            spellcheck="false"
            maxlength="7"
            pattern="#[0-9a-fA-F]{6}"
            @disabled($disabled)
            {{ $field->controlAttributes($attributes, (bool) $info)->class(['placeholder:text-muted h-full min-w-0 flex-1 bg-transparent font-mono uppercase outline-none disabled:cursor-not-allowed']) }}
        >
        {{-- The colour behind text, with black or white on it, whichever reads better. Only a picture. --}}
        <span data-color-preview aria-hidden="true" class="border-line grid h-7 shrink-0 place-items-center rounded-md border bg-(--color-picked) px-2 text-xs font-semibold text-(--color-ink) empty:hidden">@if ($current) Aa @endif</span>
    </div>

    <x-slot:after>
        @if ($swatches->isNotEmpty())
            <div role="radiogroup" aria-label="{{ trim(($label ?? 'Colour').' swatches') }}" class="mt-3 flex flex-wrap gap-2">
                @foreach ($swatches as $swatch)
                    {{-- form points at no form, so the swatches group together but never submit. --}}
                    <label class="relative cursor-pointer">
                        <input type="radio" form="{{ $field->id }}-no-form" name="{{ $field->id }}-swatch" value="{{ $swatch['color'] }}" data-color-swatch @checked($swatch['color'] === $current) @disabled($disabled) aria-label="{{ $swatch['name'] }}" class="peer sr-only">
                        <span data-color="{{ $swatch['color'] }}" class="border-line peer-checked:ring-primary peer-focus-visible:ring-primary peer-focus-visible:ring-offset-surface block size-8 rounded-full border bg-(--color-swatch) peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-offset-surface peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-50"></span>
                    </label>
                @endforeach
            </div>
        @endif
        <span role="status" data-color-status class="sr-only"></span>
    </x-slot:after>
</x-widget.field>
resources/js/widget/color-picker/index.js Show
index.js
// Drives <x-widget.color-picker>. The hex box, the browser's own colour input and the swatches stay in step: picking a
// swatch or a custom colour fills the box (and tells wire:model and other listeners), and typing a full #rrggbb or #rgb
// moves the others. On leaving the box, its text is tidied (#ABC → #aabbcc), or put back to the last colour if it isn't
// one, and a screen reader hears why. Colours are painted through CSS variables, set here, as no style attribute may be
// in the markup.

import { onLivewireMorph } from '../field';

const ROOT = '[data-color-picker]';

const fieldOf = (element) => element.closest('[data-field]');
const partsOf = (element) => {
    const field = fieldOf(element);

    return {
        hex: field.querySelector('[data-color-hex]'),
        native: field.querySelector('[data-color-native]'),
        preview: field.querySelector('[data-color-preview]'),
        swatches: [...field.querySelectorAll('[data-color-swatch]')],
        status: field.querySelector('[data-color-status]'),
    };
};

// #abc or #aabbcc (with or without #, any case) as #aabbcc; anything else, null.
function normalise(text) {
    const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text.trim());
    if (!match) {
        return null;
    }
    const digits = match[1].toLowerCase();

    return `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`;
}

// Black or white text on the colour, whichever has more contrast (WCAG relative luminance).
function ink(color) {
    const [r, g, b] = [1, 3, 5].map((at) => {
        const channel = parseInt(color.slice(at, at + 2), 16) / 255;

        return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
    });
    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;

    return (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05) ? '#000000' : '#ffffff';
}

// Paints the preview and every swatch, and moves the others to the colour in the box.
function show(parts, color) {
    if (parts.native && color) {
        parts.native.value = color;
    }
    parts.swatches.forEach((swatch) => {
        swatch.checked = swatch.value === color;
        swatch.nextElementSibling.style.setProperty('--color-swatch', swatch.value);
    });
    if (parts.preview) {
        parts.preview.textContent = color ? 'Aa' : '';
        parts.preview.style.setProperty('--color-picked', color || 'transparent');
        parts.preview.style.setProperty('--color-ink', color ? ink(color) : 'inherit');
    }
}

function pick(parts, color) {
    parts.hex.value = color;
    parts.hex.dataset.lastColor = color;
    show(parts, color);
    parts.hex.dispatchEvent(new Event('input', { bubbles: true }));
    parts.hex.dispatchEvent(new Event('change', { bubbles: true }));
}

document.addEventListener('change', (event) => {
    if (event.target.matches?.('[data-color-swatch]')) {
        pick(partsOf(event.target), event.target.value);
    }
});

document.addEventListener('input', (event) => {
    const target = event.target;
    if (target.matches?.('[data-color-native]')) {
        pick(partsOf(target), target.value);
    } else if (target.matches?.('[data-color-hex]')) {
        // A full colour typed moves the others at once; a half-typed one waits.
        const color = normalise(target.value);
        if (color) {
            target.dataset.lastColor = color;
            show(partsOf(target), color);
        }
    }
});

document.addEventListener('focusout', (event) => {
    const hex = event.target.matches?.('[data-color-hex]') ? event.target : null;
    if (!hex || hex.value.trim() === '') {
        return;
    }
    const parts = partsOf(hex);
    const color = normalise(hex.value);
    if (color) {
        if (hex.value !== color) {
            pick(parts, color);
        }

        return;
    }
    const last = hex.dataset.lastColor ?? '';
    parts.status.textContent = `${hex.value.trim()} isn't a colour, so it's back to ${last || 'empty'}.`;
    pick(parts, last);
});

// As the page loads, after a Livewire render, and for pages swapped in by wire:navigate: paint from the box.
function paint(scope = document) {
    const roots = scope instanceof Element && scope.matches(ROOT) ? [scope] : [...(scope.querySelectorAll?.(ROOT) ?? [])];
    roots.forEach((root) => {
        const parts = partsOf(root);
        const color = normalise(parts.hex.value) ?? '';
        parts.hex.dataset.lastColor = color;
        show(parts, color);
    });
}
// A frame later: Livewire 3 sets the bound box's value just after the render it reports (checked in a browser against
// Livewire 3.8 and 4.4), and painting straight away would show the old colour.
onLivewireMorph((element) => requestAnimationFrame(() => paint(fieldOf(element) ?? element)));
document.addEventListener('livewire:navigated', () => paint());

paint();
app/View/Widget/ElementIds.php Show
ElementIds.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Container\Attributes\Scoped;
use LogicException;

/**
 * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
 * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
 */
#[Scoped]
final class ElementIds
{
    /** @var array<string, true> */
    private array $used = [];

    /**
     * Reserves an id for this response.
     *
     * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
     * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
     * break that reference; a duplicate throws instead, which surfaces in development and tests.
     */
    public function claim(string $id, bool $explicit = false): string
    {
        if (! isset($this->used[$id])) {
            return $this->reserve($id);
        }

        if ($explicit) {
            throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
        }

        $suffix = 2;
        while (isset($this->used["{$id}-{$suffix}"])) {
            $suffix++;
        }

        return $this->reserve("{$id}-{$suffix}");
    }

    private function reserve(string $id): string
    {
        $this->used[$id] = true;

        return $id;
    }
}
app/View/Widget/FormField.php Show
FormField.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;

/**
 * Server-side state of one form widget: its dot-notation key, a valid id, its validation
 * messages and its old input. Every <x-widget.input.*> and the date picker resolve through
 * here, so array names (items[0][date]) and named error bags behave the same everywhere.
 */
final class FormField
{
    /**
     * @param  list<string>  $errors
     */
    private function __construct(
        public readonly ?string $name,
        public readonly string $id,
        public readonly ?string $key,
        public readonly array $errors,
        // The property a wire:model or x-model attribute binds it to, if any.
        public readonly ?string $bound = null,
    ) {}

    /**
     * @param  mixed  $errorBag  the view's shared $errors (absent outside a web request)
     * @param  string|array<int, string>|null  $error  an explicit message from the caller; overrides the bag
     */
    public static function make(
        ?string $name,
        ?string $id,
        mixed $errorBag,
        string|array|null $error = null,
        string $bag = 'default',
        string $idPrefix = 'field',
        ?ComponentAttributeBag $attributes = null,
    ): self {
        // With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
        // that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
        // (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
        $bound = $attributes === null ? null : self::boundTo($attributes);
        $key = match (true) {
            $name !== null && $name !== '' => self::key($name),
            $bound !== null => self::key($bound),
            default => null,
        };

        $messages = match (true) {
            $error !== null => Arr::wrap($error),
            $key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
            default => [],
        };

        return new self(
            $name,
            app(ElementIds::class)->claim(
                $id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
                explicit: $id !== null,
            ),
            $key,
            array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
            $bound,
        );
    }

    /**
     * The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
     * reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
     * numbered children count, so a field named address doesn't take errors meant for address[city].
     *
     * @return list<string>
     */
    private static function messagesFor(MessageBag $bag, string $key): array
    {
        $items = array_filter(
            $bag->getMessages(),
            static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
            ARRAY_FILTER_USE_KEY,
        );

        return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
    }

    /**
     * items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
     */
    public static function key(string $name): string
    {
        return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
    }

    /**
     * The id a field named $name gets when it's the first of that name on the page, for links to it
     * (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
     */
    public static function idFor(string $name): string
    {
        return self::idFrom(self::key($name));
    }

    /**
     * The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
     */
    public static function boundTo(ComponentAttributeBag $attributes): ?string
    {
        return array_values(self::binding($attributes))[0] ?? null;
    }

    private static function idFrom(string $key): string
    {
        return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
    }

    public function hasError(): bool
    {
        return $this->errors !== [];
    }

    public function errorId(): string
    {
        return $this->id.'-error';
    }

    public function infoId(): string
    {
        return $this->id.'-info';
    }

    /**
     * Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
     * property: a re-render then draws the field as it is, which Livewire morphs onto the page.
     */
    public function old(mixed $default = null): mixed
    {
        if ($default === null) {
            [$found, $live] = $this->fromLivewire();
            $default = $found ? $live : null;
        }

        return $this->key === null ? $default : old($this->key, $default);
    }

    /**
     * The bound property's value while Livewire renders the component that holds it: Livewire shares that component
     * with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
     * $key reads inside it: a range bound to period reads period.start.
     *
     * @return array{0: bool, 1: mixed}
     */
    public function fromLivewire(?string $key = null): array
    {
        $component = $this->bound === null ? null : view()->shared('__livewire');

        return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
    }

    /**
     * The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
     * picker binds period.start and period.end with the same modifiers. Empty without one.
     *
     * @return array<string, string>
     */
    public static function binding(ComponentAttributeBag $attributes): array
    {
        foreach ($attributes->getAttributes() as $attribute => $value) {
            if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
                return [$attribute => $value];
            }
        }

        return [];
    }

    /**
     * Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
     * failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
     * with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
     *
     * @param  bool  $alwaysSent  it has an unchecked-value, so its form always sends something under its name
     * @param  mixed  $errorBag  the view's shared $errors
     */
    public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
    {
        // Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
        [$found, $live] = $this->fromLivewire();
        if ($found) {
            return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
        }
        if ($this->key === null || $disabled || ! session()->hasOldInput()) {
            return $default;
        }

        $old = old($this->key);
        if ($old !== null) {
            return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
        }
        // Nothing under this name, though this box always sends something: its form wasn't the one submitted.
        if ($alwaysSent) {
            return $default;
        }
        // A form with its own error bag: no errors in that bag means the failed submit was a different form.
        if ($bag !== 'default') {
            return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
        }

        // One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
        return false;
    }

    /** Values cast to backed enums (Plan::Pro) compare as their backing value. */
    private static function text(mixed $value): string
    {
        return (string) ($value instanceof \BackedEnum ? $value->value : $value);
    }

    /**
     * What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
     * this widget manages itself. Goes on the element that is actually submitted.
     */
    public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
    }

    /**
     * aria-invalid plus one aria-describedby that joins the error or the hint, and the caller's own
     * ids. Two separate aria-describedby attributes would make the browser silently drop one.
     */
    public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        // Not both: the frame hides the hint while there's an error, and aria-describedby reads hidden text, so a
        // screen reader would hear two messages that often say the same thing. resources/js/field brings the hint
        // back when the error clears.
        $describedBy = array_filter([
            $this->hasError() ? $this->errorId() : null,
            $hasInfo && ! $this->hasError() ? $this->infoId() : null,
            $attributes->get('aria-describedby'),
        ]);

        return new ComponentAttributeBag([
            'aria-invalid' => $this->hasError() ? 'true' : null,
            'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
        ]);
    }

    /**
     * For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
     * hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
     */
    public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
    }

    /**
     * The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
     * placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
     */
    public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => ! self::isBinding($key)), $hasInfo);
    }

    private static function isBinding(string $key): bool
    {
        return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
    }

    /**
     * forwarded() and aria() together, for widgets whose visible control is also the submitted one.
     */
    public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
    }
}