Size guide
A chart of body or product measurements for each size, switchable between centimetres and inches, with one size marked out.
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 size-guidepnpm dlx ermineui add size-guideyarn dlx ermineui add size-guidebunx ermineui add size-guideThat copies 3 files into src/components/ui/size-guide/, and the 1 component it is built from. Files you already have are kept.
Use
import { SizeGuide, findSize, formatMeasurement, toInches } from '@/components/ui/size-guide'<ComparisonTable caption="Mugs compared" :columns="[{ key: 'mug', title: 'Mug' }, { key: 'travel', title: 'Travel mug', highlight: true }]" :rows="[{ label: 'Lid included', values: { mug: false, travel: true } }]" />
<PricingTable v-model:billing="billing" :plans="plans" current="hobby" @select="(plan, billing) => choose(plan, billing)" />
<SizeGuide v-model:unit="unit" :measures="[{ key: 'chest', label: 'Chest' }]" :sizes="[{ size: 'M', measurements: { chest: [96, 101] } }]" selected="M" />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 |
|---|---|
SizeGuide.vue | src/components/ui/size-guide/SizeGuide.vue |
index.ts | src/components/ui/size-guide/index.ts |
units.ts | src/components/ui/size-guide/units.ts |
It is built from: Segmented control. These are added with it.
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.
SizeGuide.vue 146 lines
<script setup lang="ts">
import { SegmentedControl, SegmentedControlItem } from '../segmented-control'
import { formatMeasurement, type SizeGuideMeasure, type SizeGuideRow, type SizeUnit } from './units'
withDefaults(
defineProps<{
/** What is measured, one column each: chest, waist, hips. */
measures: readonly SizeGuideMeasure[]
/** One row per size, with measurements in centimetres. */
sizes: readonly SizeGuideRow[]
/** Accessible name of the table, read out as its caption. */
caption?: string
/** A size to mark out, e.g. the one chosen on the product page. */
selected?: string
/** What the first column is called. */
sizeLabel?: string
}>(),
{ caption: 'Size guide', sizeLabel: 'Size' },
)
/** The unit the measurements are shown in. */
const unit = defineModel<SizeUnit>('unit', { default: 'cm' })
</script>
<template>
<div class="er-size-guide">
<div class="er-size-guide-top">
<!-- A line of advice: how to measure, or how the cut runs. -->
<p class="er-size-guide-note"><slot>Measurements are of the body, not the garment.</slot></p>
<SegmentedControl v-model="unit" label="Units" size="sm">
<SegmentedControlItem value="cm">cm</SegmentedControlItem>
<SegmentedControlItem value="in">in</SegmentedControlItem>
</SegmentedControl>
</div>
<div class="er-size-guide-scroll" role="region" :aria-label="caption" tabindex="0">
<table>
<caption class="er-size-guide-hidden">{{ caption }}, in {{ unit === 'cm' ? 'centimetres' : 'inches' }}</caption>
<thead>
<tr>
<th scope="col">{{ sizeLabel }}</th>
<th v-for="measure in measures" :key="measure.key" scope="col">{{ measure.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in sizes" :key="row.size" :data-selected="row.size === selected ? '' : undefined">
<th scope="row">{{ row.size }}<span v-if="row.size === selected" class="er-size-guide-hidden"> (selected)</span></th>
<td v-for="measure in measures" :key="measure.key">{{ formatMeasurement(row.measurements[measure.key], unit) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style>
@layer components {
.er-size-guide {
display: grid;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.er-size-guide-top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
}
.er-size-guide-note {
margin: 0;
color: var(--er-muted-foreground);
font-size: 0.8125rem;
line-height: 1.25rem;
}
.er-size-guide-scroll {
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
overflow-x: auto;
/* Positioned, so the text hidden for screen readers inside is clipped with the rest; otherwise it would widen the page from cells scrolled out of view. */
position: relative;
}
.er-size-guide-scroll:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-size-guide table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
line-height: 1.25rem;
}
.er-size-guide th,
.er-size-guide td {
padding: 0.5rem 0.875rem;
border-bottom: 1px solid var(--er-border);
text-align: right;
white-space: nowrap;
}
.er-size-guide tr > :first-child {
text-align: left;
}
.er-size-guide tbody tr:last-child > * {
border-bottom: 0;
}
.er-size-guide thead th {
background: var(--er-muted);
color: var(--er-muted-foreground);
font-size: 0.8125rem;
font-weight: var(--er-weight-medium);
}
.er-size-guide tbody th {
font-weight: var(--er-weight-semibold);
}
/* The chosen size: tinted, with a bar down its leading edge so colour is not the only mark. */
.er-size-guide tr[data-selected] > * {
background: color-mix(in oklab, var(--er-primary) 7%, transparent);
}
.er-size-guide tr[data-selected] > :first-child {
box-shadow: inset 3px 0 0 var(--er-primary);
}
.er-size-guide-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
</style>index.ts 4 lines
/** Size guide — a chart of body or product measurements for each size, switchable between centimetres and inches, with one size marked out. */
export { default as SizeGuide } from './SizeGuide.vue'
export { findSize, formatMeasurement, toInches, type SizeGuideMeasure, type SizeGuideRow, type SizeUnit } from './units'units.ts 37 lines
export type SizeUnit = 'cm' | 'in'
export interface SizeGuideMeasure {
key: string
label: string
}
export interface SizeGuideRow {
/** What the size is called: "M", "38", "UK 10". */
size: string
/** Each measurement in centimetres, by measure key. A pair is a range: [88, 92]. */
measurements: Record<string, number | readonly [number, number]>
}
/** Centimetres as inches, to the nearest half: tape measures and size charts do not go finer than that. */
export function toInches(cm: number): number {
return Math.round((cm / 2.54) * 2) / 2
}
/** One measurement written out in a unit: "92", "36", or "88–92" for a range. */
export function formatMeasurement(value: number | readonly [number, number] | undefined, unit: SizeUnit): string {
if (value === undefined) return ''
const one = (cm: number) => String(unit === 'in' ? toInches(cm) : Math.round(cm * 10) / 10)
return typeof value === 'number' ? one(value) : `${one(value[0])}–${one(value[1])}`
}
/** The first size whose range holds a measurement taken in centimetres, or the first that is big enough. */
export function findSize(rows: readonly SizeGuideRow[], key: string, cm: number): string | undefined {
for (const row of rows) {
const value = row.measurements[key]
if (value === undefined) continue
const top = typeof value === 'number' ? value : value[1]
if (cm <= top) return row.size
}
return undefined
}