Patterns · Settings
Settings list
A schema-driven group of settings rows — semibold label left (with a "?" help tooltip), control right — reusing existing DS controls.
Examples
Example
Density
Schema-driven · sections → rows · reuses DS controls · "?" help tooltip · inline + stack · disabled + plan-gated (crown) rows
Slot form · a "?" help row, an inline row, a stack row (still supported)
<!-- Paste-and-run: save as .html and open in a browser. No build step.
<aha-settings-list> is schema-driven — pass a schema (here as the JSON `schema` attribute)
and it renders each row, REUSING existing DS controls (<aha-switch>, <aha-input>…) — it ships
no control of its own. The by-hand slot form is shown second. Theming comes only from the
--aha-* tokens in tokens.css. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/tokens.css">
<script type="module">
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-settings-list.js'; // registers <aha-settings-list> + <aha-settings-item>
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-switch.js'; // reused as a schema control
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-input.js'; // reused as a schema control
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/icons.js'; // <aha-icon> — the select's caret + the crown
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-select.js'; // reused as a schema control (control.type "select")
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-paywall.js'; // reused for a plan-gated (locked) row
</script>
<!-- 1) Schema-driven — the list renders the rows and reuses the named DS controls.
`description` becomes a "?" help tooltip after the label (not a standing line); `layout:"stack"`
puts the label above a full-width control. A row can be disabled (dimmed) or locked
(visible-but-gated → the shared Paywall crown). -->
<aha-settings-list style="max-width:360px; display:block"
schema='{"sections":[{"label":"Presentation","rows":[
{"key":"progressBar","label":"Progress bar","control":{"type":"switch","checked":true}},
{"key":"leaderboard","label":"Leaderboard","description":"Shown to the audience between slides.","control":{"type":"switch"}},
{"key":"deckTitle","label":"Deck title","control":{"type":"input","value":"Untitled"}},
{"key":"notes","label":"Speaker notes","description":"Only you see these while presenting.","layout":"stack","control":{"type":"input","value":""}},
{"key":"theme","label":"Slide theme","control":{"type":"select","value":"light","options":[{"label":"Light","value":"light"},{"label":"Dark","value":"dark"},{"label":"High contrast","value":"contrast"}]}},
{"key":"timer","label":"Question timer","disabled":true,"control":{"type":"switch"}},
{"key":"branding","label":"Custom branding","locked":true,"plan":"pro","feature":"Custom branding"}
]}]}'></aha-settings-list>
<script type="module">
document.querySelector('aha-settings-list')
.addEventListener('change', e => console.log('setting changed', e.detail)); // { key, value, name }
</script>
<!-- 2) Slot form — compose the controls by hand (still supported) -->
<aha-settings-list label="Audience" style="max-width:360px; display:block; margin-top:24px">
<aha-settings-item label="Anonymous join">
<aha-switch slot="control" checked></aha-switch>
</aha-settings-item>
</aha-settings-list>
import { useEffect, useRef } from 'react';
import '@ahaslides-product/design/aha-settings-list'; // registers <aha-settings-list> + <aha-settings-item>
import '@ahaslides-product/design/aha-switch'; // reused as schema controls
import '@ahaslides-product/design/aha-input';
import '@ahaslides-product/design/aha-paywall'; // reused for a plan-gated (locked) row
// Schema-driven — set the schema property (an object) and listen for the change event.
// `description` renders as a "?" help tooltip after the label (not a standing line);
// `layout: 'stack'` puts the label above a full-width control (wide inputs). A row can be
// disabled (dimmed) or locked (visible-but-gated → the shared Paywall crown).
function DeckSettings() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
el.schema = {
sections: [{ label: 'Presentation', rows: [
{ key: 'progressBar', label: 'Progress bar', control: { type: 'switch', checked: true } },
{ key: 'leaderboard', label: 'Leaderboard', description: 'Shown between slides.', control: { type: 'switch' } },
{ key: 'deckTitle', label: 'Deck title', control: { type: 'input', value: 'Untitled' } },
{ key: 'notes', label: 'Speaker notes', layout: 'stack', description: 'Only you see these.', control: { type: 'input' } },
{ key: 'timer', label: 'Question timer', disabled: true, control: { type: 'switch' } },
{ key: 'branding', label: 'Custom branding', locked: true, plan: 'pro', feature: 'Custom branding' },
] }],
};
const onChange = (e) => console.log(e.detail); // { key, value, name }
el.addEventListener('change', onChange);
return () => el.removeEventListener('change', onChange);
}, []);
return <aha-settings-list ref={ref} />;
}
// Or compose by hand with the slot form — name = noun phrase (no leading verb).
<aha-settings-list label="Audience">
<aha-settings-item label="Anonymous join">
<aha-switch slot="control" checked />
</aha-settings-item>
</aha-settings-list>
// main.ts — register the elements + mark aha-* as custom elements
import '@ahaslides-product/design/aha-settings-list'; // registers <aha-settings-list> + <aha-settings-item>
import '@ahaslides-product/design/aha-switch'; // reused as schema controls
import '@ahaslides-product/design/aha-input';
import '@ahaslides-product/design/aha-paywall'; // reused for a plan-gated (locked) row
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('aha-');
// Component.vue — schema-driven: bind the schema property, listen for change.
// `description` renders as a "?" help tooltip after the label (not a standing line);
// `layout: 'stack'` puts the label above a full-width control. A row can be disabled
// (dimmed) or locked (visible-but-gated → the shared Paywall crown).
<script setup>
import { ref, onMounted } from 'vue';
const list = ref(null);
const schema = {
sections: [{ label: 'Presentation', rows: [
{ key: 'progressBar', label: 'Progress bar', control: { type: 'switch', checked: true } },
{ key: 'leaderboard', label: 'Leaderboard', description: 'Shown between slides.', control: { type: 'switch' } },
{ key: 'deckTitle', label: 'Deck title', control: { type: 'input', value: 'Untitled' } },
{ key: 'notes', label: 'Speaker notes', layout: 'stack', description: 'Only you see these.', control: { type: 'input' } },
{ key: 'timer', label: 'Question timer', disabled: true, control: { type: 'switch' } },
{ key: 'branding', label: 'Custom branding', locked: true, plan: 'pro', feature: 'Custom branding' },
] }],
};
onMounted(() => { list.value.schema = schema; });
</script>
<template>
<!-- schema-driven -->
<aha-settings-list ref="list" @change="e => console.log(e.detail)" />
<!-- or the by-hand slot form -->
<aha-settings-list label="Audience">
<aha-settings-item label="Anonymous join">
<aha-switch slot="control" checked />
</aha-settings-item>
</aha-settings-list>
</template>
API
| Prop | Type | Default | Notes |
|---|---|---|---|
schema | object | — | (list, property) Settings schema { sections:[{ label, rows:[{ key, label, description?, layout?, consequence?, control }] }] } — an array is treated as one section's rows; renders rows and reuses DS controls. Also settable as a JSON `schema` attribute for the build-free HTML form A row may declare visible_if (structured `visibleIf:{key,equals?,in?,not?}` or the Shopify string `visible_if:"{{ settings.<key> }}"`) — the row is shown only when its trigger is on and hidden when off, rendered nested (indented, de-emphasised). |
label | string | — | Group header (a 1–3 word noun phrase); on <aha-settings-item> it is the setting name. Ignored when a schema supplies section labels |
description | string | — | (item / schema row) Guidance text — rendered as the "?" HELP TOOLTIP after the label (DS <aha-tooltip help>), NOT a standing line. Default: no help glyph |
consequence | string | — | (item / schema row) RARE opt-in must-see-consequence line shown as a standing line under the label (e.g. 'This cannot be undone.') — default none |
layout | 'inline' | 'stack' | inline | (item / schema row) inline = label left / control right (align-items:center, space-between, gap 12); stack = label above / control full-width (column, gap 8) for wide controls (textarea / wide select) |
control | slot | schema | — | (item) The DS control on the right — <aha-switch>, <aha-checkbox>, <aha-input>… slotted by hand, or named in a schema row's `control:{ type }` and instantiated for you |
density | 'default' | 'compact' | default | (list) Row/group rhythm. compact tightens the spacing (16 between groups, 8 between siblings) — never adds lines or boxes |
disabled | boolean | false | (item / schema row) Dims the label to text-disabled, disables the reused control, sets aria-disabled |
locked | boolean | false | (schema row) Plan-gates the row: stays visible, its control becomes the shared Paywall crown badge → upsell. Pair with `plan` ('essential'|'pro') and `feature` (the upsell label) |
Install
# .npmrc — once: point the @ahaslides-product scope at GitHub Packages
@ahaslides-product:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} # a GitHub token with read:packages
npm i @ahaslides-product/design
import '@ahaslides-product/design/tokens.css'; // once, at the app root
import '@ahaslides-product/design/aha-settings-list'; // registers <aha-settings-list>
Agent feed for this component (absolute, fetchable anywhere): settings-list.agent.json · settings-list.md · settings-list.llms.txt
When to use
When to use
- Settings list — a group of related on/off or choice settings in a panel, page or drawer
- Form — values are submitted together with validation and a submit step
- a single Switch/Select — one setting that applies on its own
Name each setting as a noun phrase (no leading verb — the control says on/off). Use a toggle for immediate effect, a checkbox for consent/saved-together — never mix in one group. Guidance lives in the "?" help tooltip after the label — never a standing description line (reserve the rare `consequence` line for a genuine must-see, e.g. an irreversible action). Hierarchy is spacing, never lines or boxes. Use `layout:'stack'` for wide controls (textarea / wide select). See the settings pattern for grouping, sub-settings and the danger zone.
Surfaces
editor settings dashboard
Spec
Group column · 16px between sibling settings · 32px between groups · semibold (600) header (SectionHeader); member/row labels are ALSO semibold (600) per the reference SettingRow · Row (inline, default) label left, control right · align-items:center · justify-content:space-between · min-height 24 · 12px gap · Row (stack) layout=stack → label above, control full-width (flex-direction:column · 8px gap) — for wide controls (textarea / wide select) · Label 14/21 Semibold (600) #1A1A1A (name = noun phrase, no leading verb) · Help guidance = a "?" help trigger after the label (DS <aha-tooltip help>, icon system-question-mark, muted ~50% = text-tertiary #8A8A8A → brand on hover/focus) + tooltip; NO standing description line. `description` is the tooltip content · Consequence (rare) opt-in `consequence` → a standing must-see line 12/18 #8A8A8A under the label; default none · Disabled row row disabled → label dims to text-disabled #B5B5B5, control disabled, aria-disabled · Plan-gated row row locked → visible but the control is the shared Paywall crown (opens the upsell), never hidden · Density density=compact tightens the rhythm (16 between groups, 8 between siblings) — spacing, never lines · Structure no divider lines, no card/box around plain settings · Schema sections[] → rows[] { key, label, description?, layout?, consequence?, disabled?, locked?, plan?, feature?, control:{ type, …props } }; renders + reuses DS controls · visible_if a follow-up row shown only when its trigger is on (Shopify model) — nested (24 indent, tighter gap, de-emphasised label); hidden = display:none, no phantom gap (SETTINGS-19/07/14)