Swatch
One choice from a few, shown as colour dots or small labelled boxes, with unavailable ones struck through.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add swatchpnpm dlx ermineui add swatchyarn dlx ermineui add swatchbunx ermineui add swatchThat copies 4 files into src/components/ui/swatch/, 1 helper into src/lib/ermine/. Files you already have are kept.
Use
import { SwatchGroup, Swatch } from '@/components/ui/swatch'<Price :amount="96" :compare-at="120" show-saving /> <!-- $96.00, $120.00 struck through, "Save 20%" -->
<Price :amount="9" unit="/ month" currency="EUR" locale="de-DE" />
<SwatchGroup v-model="colour" label="Colour">
<Swatch value="sand" color="#c9b79c" label="Sand" />
<Swatch value="chalk" color="#f5f3ee" label="Chalk" unavailable />
</SwatchGroup>
<SwatchGroup v-model="size" label="Size">
<Swatch value="s">S</Swatch>
<Swatch value="m">M</Swatch>
</SwatchGroup>
<ProductCard title="Wool throw" href="/products/wool-throw" image="/img/throw.jpg" brand="Fell & Co" :price="96" :compare-at="120" :rating="4.8" :reviews="64" badge="Sale">
<template #action><Button variant="secondary" size="icon" aria-label="Save for later"><Icon name="heart" /></Button></template>
<Button variant="outline" size="sm" @click="add">Add to cart</Button>
</ProductCard>
<ProductGallery v-model="picture" :images="[{ src: '/img/mug-1.jpg', alt: 'Front of the mug' }, …]" thumbnails="left" zoom />In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Swatch.vue | src/components/ui/swatch/Swatch.vue |
SwatchGroup.vue | src/components/ui/swatch/SwatchGroup.vue |
context.ts | src/components/ui/swatch/context.ts |
index.ts | src/components/ui/swatch/index.ts |
context.ts helper | src/lib/ermine/context.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Swatch.vue 45 lines
<script setup lang="ts">
import { computed } from 'vue'
import { useSwatchGroup } from './context'
const props = defineProps<{
value: string
/** Any CSS colour. With one, the swatch is a round dot of that colour and `label` names it. */
color?: string
/** Accessible name. Needed for a colour; otherwise the text inside is used. */
label?: string
/** Sold out, or not made in this combination: shown struck through and cannot be chosen. */
unavailable?: boolean
}>()
const group = useSwatchGroup()
const on = computed(() => group.value.value === props.value)
const name = computed(() => {
const text = props.label ?? (props.color ? props.value : undefined)
// Without a label the text inside names the radio button, and being disabled says the rest.
return props.unavailable && text ? `${text}, not available` : text
})
</script>
<template>
<label
class="er-swatch"
:data-state="on ? 'on' : 'off'"
:data-color="color ? '' : undefined"
:data-unavailable="unavailable ? '' : undefined"
:style="color ? { '--er-swatch-color': color } : undefined"
:title="color ? (label ?? value) : undefined"
>
<input
type="radio"
:name="group.name.value"
:value="value"
:checked="on"
:disabled="unavailable || group.disabled.value"
:aria-label="name"
@change="group.select(value)"
/>
<slot v-if="!color">{{ label ?? value }}</slot>
</label>
</template>SwatchGroup.vue 136 lines
<script setup lang="ts">
import { computed, toRef, useId } from 'vue'
import { provideSwatchGroup } from './context'
const props = defineProps<{
/** Accessible name: what is being chosen, e.g. "Colour" or "Size". */
label: string
/** Submit the choice with a form under this name. One is made up when you leave it out. */
name?: string
disabled?: boolean
}>()
const value = defineModel<string>()
const fallbackName = useId()
provideSwatchGroup({
value,
select: (next) => {
value.value = next
},
name: computed(() => props.name ?? fallbackName),
disabled: toRef(props, 'disabled'),
})
</script>
<template>
<div class="er-swatch-group" role="radiogroup" :aria-label="label">
<slot />
</div>
</template>
<style>
@layer components {
.er-swatch-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.er-swatch {
position: relative;
display: inline-grid;
place-items: center;
min-width: 2.5rem;
height: var(--er-control-md);
padding: 0 0.75rem;
border: 1px solid var(--er-input);
border-radius: var(--er-radius-control);
background: var(--er-input-background);
color: var(--er-foreground);
font-size: 0.875rem;
font-weight: var(--er-weight-medium);
line-height: 1;
cursor: pointer;
user-select: none;
transition:
background-color var(--er-duration),
border-color var(--er-duration),
box-shadow var(--er-duration);
}
.er-swatch:hover {
background: var(--er-accent);
}
.er-swatch[data-state='on'] {
border-color: var(--er-primary);
background: var(--er-primary);
color: var(--er-primary-foreground);
}
/* A real radio button covers the whole swatch, so forms, labels and the arrow keys work as usual. */
.er-swatch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.er-swatch:has(input:focus-visible) {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
/* A colour: a round dot. The chosen one gets a ring around it, so colour is not what marks the choice. */
.er-swatch[data-color] {
width: 2rem;
min-width: 0;
height: 2rem;
padding: 0;
border-color: color-mix(in oklab, var(--er-foreground) 20%, transparent);
border-radius: 50%;
background: var(--er-swatch-color);
}
.er-swatch[data-color][data-state='on'] {
border-color: color-mix(in oklab, var(--er-foreground) 20%, transparent);
background: var(--er-swatch-color);
box-shadow:
0 0 0 2px var(--er-background),
0 0 0 4px var(--er-primary);
}
/* Not available: faded, with a line through it. */
.er-swatch[data-unavailable] {
color: var(--er-muted-foreground);
cursor: not-allowed;
}
.er-swatch[data-unavailable]:hover {
background: var(--er-input-background);
}
.er-swatch[data-unavailable]::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(to top right, transparent calc(50% - 1px), var(--er-muted-foreground) calc(50% - 1px), var(--er-muted-foreground) calc(50% + 1px), transparent calc(50% + 1px));
pointer-events: none;
}
.er-swatch[data-color][data-unavailable] {
background: color-mix(in oklab, var(--er-swatch-color) 35%, var(--er-background));
}
.er-swatch[data-color][data-unavailable]:hover {
background: color-mix(in oklab, var(--er-swatch-color) 35%, var(--er-background));
}
}
</style>context.ts 13 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'
export interface SwatchGroupContext {
value: Ref<string | undefined>
select: (value: string) => void
/** Shared by every swatch in the group: it is what makes the arrow keys move between them. */
name: Ref<string>
disabled: Ref<boolean>
}
export const [provideSwatchGroup, useSwatchGroup] = createContext<SwatchGroupContext>('SwatchGroup')index.ts 4 lines
/** Swatch — one choice from a few, shown as colour dots or small labelled boxes, with unavailable ones struck through. */
export { default as SwatchGroup } from './SwatchGroup.vue'
export { default as Swatch } from './Swatch.vue'