diff --git a/demo/partials/cards.html b/demo/partials/cards.html
index a9e6895..154f7f6 100644
--- a/demo/partials/cards.html
+++ b/demo/partials/cards.html
@@ -86,4 +86,25 @@
`
}).mount("#vue-cards-app");
+
+
Vue action card
+
+
diff --git a/demo/partials/inputs.html b/demo/partials/inputs.html
index 3329df7..03f6f26 100644
--- a/demo/partials/inputs.html
+++ b/demo/partials/inputs.html
@@ -103,4 +103,57 @@
`
}).mount("#vue-search-field-app");
+
+ Vue copy button
+
+
+
+ Vue range
+
+
+
+ Vue tag input
+
+
diff --git a/demo/partials/lists.html b/demo/partials/lists.html
index ef40328..22e5832 100644
--- a/demo/partials/lists.html
+++ b/demo/partials/lists.html
@@ -1,4 +1,4 @@
-
+
Lists
Default list
@@ -24,4 +24,31 @@
Sent
Drafts
+
+ Vue action list
+
+
diff --git a/src/scss/components/_buttons.scss b/src/scss/components/_buttons.scss
index eb7ea68..69da530 100644
--- a/src/scss/components/_buttons.scss
+++ b/src/scss/components/_buttons.scss
@@ -150,6 +150,17 @@
&.btn-xxs { width: 24px; }
}
+.btn-icon {
+ aspect-ratio: 1 / 1;
+ padding: 0;
+ border-radius: $radius-md;
+
+ &.btn-icon-sm {
+ width: 32px;
+ height: 32px;
+ }
+}
+
// Variants aligned with Figma Button.png
// Primary — filled yellow CTA
diff --git a/src/scss/components/_cards.scss b/src/scss/components/_cards.scss
index dafe896..403fe34 100644
--- a/src/scss/components/_cards.scss
+++ b/src/scss/components/_cards.scss
@@ -91,4 +91,37 @@
.card-content { padding: $space-6; }
.card-footer { padding: $space-4 $space-6; }
}
+
+ // Action card layout
+ &.action-card {
+ .action-card-kicker {
+ display: block;
+ margin-bottom: $space-2;
+ font-size: 12px;
+ font-weight: $font-weight-semibold;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: $neutral-500;
+ }
+
+ .action-card-title {
+ margin: 0 0 $space-2;
+ font-size: 20px;
+ font-weight: $font-weight-bold;
+ color: $neutral-900;
+ }
+
+ .action-card-text {
+ margin: 0 0 $space-4;
+ color: $neutral-700;
+ font-size: 15px;
+ line-height: 1.5;
+ }
+
+ .action-card-actions {
+ display: flex;
+ align-items: center;
+ gap: $space-3;
+ }
+ }
}
diff --git a/src/scss/components/_forms.scss b/src/scss/components/_forms.scss
index a8c3388..ca81f26 100644
--- a/src/scss/components/_forms.scss
+++ b/src/scss/components/_forms.scss
@@ -198,3 +198,56 @@
grid-template-columns: 1fr;
}
}
+
+.range {
+ display: flex;
+ flex-direction: column;
+ gap: $space-2;
+ width: 100%;
+ max-width: 400px;
+
+ .range-input {
+ width: 100%;
+ height: 6px;
+ margin: $space-2 0;
+ appearance: none;
+ background: $neutral-200;
+ border-radius: $radius-full;
+ cursor: pointer;
+
+ &::-webkit-slider-thumb {
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: $neutral-900;
+ border: 2px solid $neutral-0;
+ box-shadow: 0 1px 4px rgba($neutral-900, 0.16);
+ transition: transform $motion-base $motion-ease;
+ }
+
+ &::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: $neutral-900;
+ border: 2px solid $neutral-0;
+ box-shadow: 0 1px 4px rgba($neutral-900, 0.16);
+ transition: transform $motion-base $motion-ease;
+ }
+
+ &:hover::-webkit-slider-thumb { transform: scale(1.1); }
+ &:hover::-moz-range-thumb { transform: scale(1.1); }
+
+ &:focus {
+ outline: none;
+ &::-webkit-slider-thumb { box-shadow: 0 0 0 3px rgba($mint-600, 0.32); }
+ &::-moz-range-thumb { box-shadow: 0 0 0 3px rgba($mint-600, 0.32); }
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+}
diff --git a/src/scss/components/_lists.scss b/src/scss/components/_lists.scss
index 39772ec..2f07442 100644
--- a/src/scss/components/_lists.scss
+++ b/src/scss/components/_lists.scss
@@ -89,3 +89,51 @@
border-top: $border-width-base $border-style-base $border-color-muted;
}
}
+
+.list-actions {
+ width: 100%;
+ max-width: 420px;
+ border: 0;
+ background: transparent;
+ padding: 0;
+
+ .list-item {
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: $space-3;
+ padding: $space-3 0;
+ border-bottom: $border-width-base $border-style-base rgba($neutral-900, 0.08);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ .list-content {
+ display: flex;
+ flex-direction: column;
+ gap: $space-1;
+ }
+
+ .list-title {
+ font-size: 15px;
+ font-weight: $font-weight-semibold;
+ color: $neutral-900;
+ }
+
+ .list-subtitle {
+ font-size: 13px;
+ color: $neutral-600;
+ line-height: 1.4;
+ }
+
+ .list-controls {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ }
+
+ &.list-item-muted {
+ opacity: 0.6;
+ }
+ }
+}
diff --git a/src/scss/components/_tag-input.scss b/src/scss/components/_tag-input.scss
new file mode 100644
index 0000000..bf9c703
--- /dev/null
+++ b/src/scss/components/_tag-input.scss
@@ -0,0 +1,95 @@
+/* =========================
+ Tag input
+========================= */
+
+@use "../kit-deps" as *;
+
+.tag-input {
+ display: flex;
+ flex-direction: column;
+ gap: $space-2;
+ width: 100%;
+ max-width: 600px;
+
+ .label {
+ display: flex;
+ align-items: center;
+ gap: $space-2;
+ font-size: 14px;
+ font-weight: $font-weight-semibold;
+ color: $neutral-900;
+
+ .label-disabled-hint {
+ font-size: 12px;
+ font-weight: $font-weight-regular;
+ color: $neutral-500;
+ }
+ }
+
+ .tag-input-wrap {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: $space-2;
+ min-height: 48px;
+ padding: $space-2 $space-3;
+ background-color: $neutral-0;
+ border: $border-width-base $border-style-base $neutral-300;
+ border-radius: $radius-lg;
+ cursor: text;
+ transition:
+ border-color $motion-base $motion-ease,
+ box-shadow $motion-base $motion-ease;
+
+ &:hover {
+ border-color: $mint-600;
+ }
+ }
+
+ .tag-input-field {
+ flex: 1;
+ min-width: 80px;
+ min-height: 28px;
+ padding: $space-1 $space-2;
+ font-family: $font-family-base;
+ font-size: 15px;
+ font-weight: $font-weight-medium;
+ color: $neutral-900;
+ background: transparent;
+ border: 0;
+ outline: none;
+
+ &::placeholder {
+ color: $neutral-600;
+ font-weight: $font-weight-regular;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+ }
+
+ &.tag-input-focused .tag-input-wrap {
+ border-color: $mint-600;
+ box-shadow: 0 0 0 3px rgba($mint-600, 0.24);
+ }
+
+ &.tag-input-disabled .tag-input-wrap {
+ background-color: $neutral-50;
+ border-color: $neutral-300;
+ cursor: not-allowed;
+ }
+
+ .input-help {
+ display: flex;
+ align-items: center;
+ gap: $space-1;
+ font-size: 13px;
+ color: $neutral-600;
+
+ .vmk-icon {
+ font-size: 16px;
+ }
+ }
+}
diff --git a/src/scss/kit.scss b/src/scss/kit.scss
index b639ff5..ece7901 100644
--- a/src/scss/kit.scss
+++ b/src/scss/kit.scss
@@ -7,6 +7,7 @@
@use "components/badges";
@use "components/alerts";
@use "components/forms";
+@use "components/tag-input";
@use "components/input-group";
@use "components/select";
@use "components/checkbox";
diff --git a/src/vue/components/GnActionCard.js b/src/vue/components/GnActionCard.js
new file mode 100644
index 0000000..45477a0
--- /dev/null
+++ b/src/vue/components/GnActionCard.js
@@ -0,0 +1,34 @@
+/**
+ * GnActionCard - Card with a kicker, title, text and an actions slot.
+ *
+ * @typedef {Object} GnActionCardProps
+ * @property {string} [kicker=''] - Eyebrow text
+ * @property {string} title - Card title
+ * @property {string} [text=''] - Body text
+ *
+ * @slots default - Body content (overrides text)
+ * @slots kicker - Kicker content (overrides kicker)
+ * @slots title - Title content (overrides title)
+ * @slots actions - Footer actions
+ */
+
+import { defineComponent, h } from "vue";
+
+export default defineComponent({
+ name: "GnActionCard",
+ props: {
+ kicker: { type: String, default: "" },
+ title: { type: String, required: true },
+ text: { type: String, default: "" }
+ },
+ setup(props, { slots }) {
+ return () => h("article", { class: "card action-card" }, [
+ h("div", { class: "card-content" }, [
+ (props.kicker || slots.kicker) && h("span", { class: "action-card-kicker" }, slots.kicker?.() || props.kicker),
+ h("h3", { class: "action-card-title" }, slots.title?.() || props.title),
+ (props.text || slots.default) && h("p", { class: "action-card-text" }, slots.default?.() || props.text),
+ slots.actions && h("div", { class: "action-card-actions" }, slots.actions())
+ ])
+ ]);
+ }
+});
diff --git a/src/vue/components/GnActionList.js b/src/vue/components/GnActionList.js
new file mode 100644
index 0000000..8d23824
--- /dev/null
+++ b/src/vue/components/GnActionList.js
@@ -0,0 +1,32 @@
+/**
+ * GnActionList - List of actionable items with title, subtitle and controls slots.
+ *
+ * @typedef {Object} GnActionListProps
+ * @property {Array} [items=[]] - Items as objects with title/label, subtitle, muted, etc.
+ *
+ * @slots title - Override title for an item (scope: { item })
+ * @slots subtitle - Override subtitle for an item (scope: { item })
+ * @slots controls - Right-side controls for an item (scope: { item })
+ */
+
+import { defineComponent, h } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnActionList",
+ inheritAttrs: false,
+ props: {
+ items: { type: Array, default: () => [] }
+ },
+ setup(props, { attrs, slots }) {
+ return () => h("ul", { ...attrs, class: cx("list list-actions", attrs.class) }, props.items.map(item => h("li", {
+ class: cx("list-item", item.muted && "list-item-muted")
+ }, [
+ h("div", { class: "list-content" }, [
+ h("div", { class: "list-title" }, slots.title?.({ item }) || item.title || item.label || ""),
+ (item.subtitle || slots.subtitle) && h("div", { class: "list-subtitle" }, slots.subtitle?.({ item }) || item.subtitle)
+ ]),
+ slots.controls && h("div", { class: "list-controls" }, slots.controls({ item }))
+ ])));
+ }
+});
diff --git a/src/vue/components/GnCopyButton.js b/src/vue/components/GnCopyButton.js
new file mode 100644
index 0000000..8d0b5c7
--- /dev/null
+++ b/src/vue/components/GnCopyButton.js
@@ -0,0 +1,63 @@
+/**
+ * GnCopyButton - Copies text to the clipboard and shows a transient success state.
+ *
+ * @typedef {Object} GnCopyButtonProps
+ * @property {string} text - Text to copy
+ * @property {string} [icon='ph-copy'] - Default icon name
+ * @property {string} [successIcon='ph-check'] - Icon shown after copying
+ * @property {number} [duration=3000] - How long the success state lasts (ms)
+ * @property {string} [label='Copy'] - Accessible label
+ * @property {string} [size=''] - "sm" for a smaller button
+ *
+ * @emits copy
+ */
+
+import { defineComponent, h, ref } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnCopyButton",
+ props: {
+ text: { type: String, required: true },
+ icon: { type: String, default: "ph-copy" },
+ successIcon: { type: String, default: "ph-check" },
+ duration: { type: Number, default: 3000 },
+ label: { type: String, default: "Copy" },
+ size: { type: String, default: "" }
+ },
+ emits: ["copy"],
+ setup(props, { emit }) {
+ const copied = ref(false);
+ let timer = null;
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(props.text);
+ } catch {
+ const textarea = document.createElement("textarea");
+ textarea.value = props.text;
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+ }
+
+ copied.value = true;
+ window.clearTimeout(timer);
+ timer = window.setTimeout(() => {
+ copied.value = false;
+ }, props.duration);
+
+ emit("copy", props.text);
+ };
+
+ return () => h("button", {
+ class: cx("btn-icon", { "btn-icon-sm": props.size === "sm" }),
+ type: "button",
+ "aria-label": props.label,
+ onClick: copy
+ }, [iconNode(copied.value ? props.successIcon : props.icon)]);
+ }
+});
diff --git a/src/vue/components/GnRange.js b/src/vue/components/GnRange.js
new file mode 100644
index 0000000..74df483
--- /dev/null
+++ b/src/vue/components/GnRange.js
@@ -0,0 +1,55 @@
+/**
+ * GnRange - Accessible range/slider input.
+ *
+ * @typedef {Object} GnRangeProps
+ * @property {number|string} [modelValue=0] - Current value
+ * @property {string} [label=''] - Field label
+ * @property {number|string} [min=0] - Minimum value
+ * @property {number|string} [max=100] - Maximum value
+ * @property {number|string} [step=1] - Step increment
+ *
+ * @emits update:modelValue
+ */
+
+import { defineComponent, h } from "vue";
+import { cx, eventValue } from "../utils.js";
+
+export default defineComponent({
+ name: "GnRange",
+ inheritAttrs: false,
+ props: {
+ modelValue: { type: [Number, String], default: 0 },
+ label: { type: String, default: "" },
+ min: { type: [Number, String], default: 0 },
+ max: { type: [Number, String], default: 100 },
+ step: { type: [Number, String], default: 1 }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { attrs, emit }) {
+ return () => h("div", { class: "range" }, [
+ props.label && h("label", { class: "label" }, [
+ props.label,
+ h("input", {
+ ...attrs,
+ type: "range",
+ value: props.modelValue,
+ min: props.min,
+ max: props.max,
+ step: props.step,
+ class: cx("range-input", attrs.class),
+ onInput: event => emit("update:modelValue", eventValue(event))
+ })
+ ]),
+ !props.label && h("input", {
+ ...attrs,
+ type: "range",
+ value: props.modelValue,
+ min: props.min,
+ max: props.max,
+ step: props.step,
+ class: cx("range-input", attrs.class),
+ onInput: event => emit("update:modelValue", eventValue(event))
+ })
+ ]);
+ }
+});
diff --git a/src/vue/components/GnTagInput.js b/src/vue/components/GnTagInput.js
new file mode 100644
index 0000000..a2d3f46
--- /dev/null
+++ b/src/vue/components/GnTagInput.js
@@ -0,0 +1,179 @@
+/**
+ * GnTagInput - Input that turns typed text into removable chips/tags.
+ *
+ * @typedef {Object} GnTagInputProps
+ * @property {string[]} [modelValue=[]] - Current tag list
+ * @property {string} [label=''] - Field label
+ * @property {string} [placeholder='Add item…'] - Input placeholder
+ * @property {string} [help=''] - Help text
+ * @property {boolean} [disabled=false] - Disabled state
+ * @property {string} [separator=','] - Character used to split pasted/typed batch input
+ * @property {boolean} [unique=true] - Prevent duplicate tags
+ * @property {number} [maxItems=0] - Maximum number of tags (0 = unlimited)
+ *
+ * @emits update:modelValue
+ * @emits add
+ * @emits remove
+ */
+
+import { defineComponent, h, ref, computed } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnTagInput",
+ inheritAttrs: false,
+ props: {
+ modelValue: { type: Array, default: () => [] },
+ label: { type: String, default: "" },
+ placeholder: { type: String, default: "Add item…" },
+ help: { type: String, default: "" },
+ disabled: { type: Boolean, default: false },
+ separator: { type: String, default: "," },
+ unique: { type: Boolean, default: true },
+ maxItems: { type: Number, default: 0 }
+ },
+ emits: ["update:modelValue", "add", "remove"],
+ setup(props, { emit, attrs, slots }) {
+ const inputRef = ref(null);
+ const focused = ref(false);
+ const rawValue = ref("");
+
+ const canAdd = computed(() => {
+ if (props.disabled) return false;
+ if (props.maxItems > 0 && props.modelValue.length >= props.maxItems) return false;
+ return true;
+ });
+
+ function normalizeText(text) {
+ return text.trim().replace(/\s+/g, " ");
+ }
+
+ function addValue(text) {
+ const value = normalizeText(text);
+ if (!value) return;
+ if (props.unique && props.modelValue.includes(value)) return;
+ if (props.maxItems > 0 && props.modelValue.length >= props.maxItems) return;
+
+ const next = [...props.modelValue, value];
+ emit("update:modelValue", next);
+ emit("add", value);
+ }
+
+ function removeValue(index) {
+ const removed = props.modelValue[index];
+ const next = props.modelValue.filter((_, i) => i !== index);
+ emit("update:modelValue", next);
+ emit("remove", removed);
+ }
+
+ function onKeydown(event) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ if (rawValue.value) {
+ const parts = props.separator
+ ? rawValue.value.split(props.separator).map(s => s.trim()).filter(Boolean)
+ : [rawValue.value];
+ parts.forEach(addValue);
+ rawValue.value = "";
+ }
+ return;
+ }
+
+ if (event.key === "Backspace" && !rawValue.value && props.modelValue.length > 0) {
+ removeValue(props.modelValue.length - 1);
+ }
+ }
+
+ function onPaste(event) {
+ const paste = event.clipboardData.getData("text");
+ if (!paste || !props.separator) return;
+ event.preventDefault();
+ const parts = paste.split(props.separator).map(s => s.trim()).filter(Boolean);
+ const appended = [];
+ for (const part of parts) {
+ const v = normalizeText(part);
+ if (!v) continue;
+ if (props.unique && props.modelValue.includes(v)) continue;
+ if (props.maxItems > 0 && props.modelValue.length + appended.length >= props.maxItems) break;
+ appended.push(v);
+ }
+ if (appended.length) {
+ const next = [...props.modelValue, ...appended];
+ emit("update:modelValue", next);
+ appended.forEach(v => emit("add", v));
+ }
+ }
+
+ function onWrapClick() {
+ inputRef.value?.focus();
+ }
+
+ return () => {
+ const labelNode = (props.label || slots.label)
+ ? h("label", { class: "label" }, [
+ slots.label?.() || props.label,
+ props.disabled && h("span", { class: "label-disabled-hint" }, "Disabled")
+ ])
+ : null;
+
+ const chips = props.modelValue.map((item, index) =>
+ h("span", {
+ class: cx("chip", "chip-secondary"),
+ key: `${item}-${index}`
+ }, [
+ item,
+ !props.disabled && h("button", {
+ class: "chip-remove",
+ type: "button",
+ "aria-label": `Remove ${item}`,
+ onClick: (e) => {
+ e.stopPropagation();
+ removeValue(index);
+ }
+ }, [iconNode("ph-x")])
+ ])
+ );
+
+ const field = h("input", {
+ ref: inputRef,
+ class: "tag-input-field",
+ type: "text",
+ placeholder: canAdd.value ? props.placeholder : "",
+ value: rawValue.value,
+ disabled: props.disabled,
+ onInput: (e) => { rawValue.value = e.target.value; },
+ onKeydown,
+ onPaste,
+ onFocus: () => focused.value = true,
+ onBlur: () => focused.value = false
+ });
+
+ const wrap = h("div", {
+ class: cx("tag-input-wrap"),
+ onClick: onWrapClick
+ }, [
+ ...chips,
+ field
+ ]);
+
+ const meta = props.help
+ ? h("div", { class: "input-help" }, [
+ iconNode("ph-info"),
+ " " + props.help
+ ])
+ : null;
+
+ return h("div", {
+ ...attrs,
+ class: cx("tag-input", {
+ "tag-input-focused": focused.value,
+ "tag-input-disabled": props.disabled
+ }, attrs.class)
+ }, [
+ labelNode,
+ wrap,
+ meta
+ ]);
+ };
+ }
+});
diff --git a/src/vue/index.js b/src/vue/index.js
index 9949584..034183e 100644
--- a/src/vue/index.js
+++ b/src/vue/index.js
@@ -17,6 +17,11 @@
export { default as GnSelect } from "./components/GnSelect.js";
export { default as GnTextarea } from "./components/GnTextarea.js";
export { default as GnCard } from "./components/GnCard.js";
+export { default as GnActionCard } from "./components/GnActionCard.js";
+export { default as GnActionList } from "./components/GnActionList.js";
+export { default as GnCopyButton } from "./components/GnCopyButton.js";
+export { default as GnRange } from "./components/GnRange.js";
+export { default as GnTagInput } from "./components/GnTagInput.js";
export { default as GnModal } from "./components/GnModal.js";
export { default as GnConfirmDialog } from "./components/GnConfirmDialog.js";
export { default as GnDrawer } from "./components/GnDrawer.js";
diff --git a/src/vue/plugin.js b/src/vue/plugin.js
index deaa856..d1b7ea8 100644
--- a/src/vue/plugin.js
+++ b/src/vue/plugin.js
@@ -16,6 +16,11 @@
import GnSwitch from "./components/GnSwitch.js";
import GnSelect from "./components/GnSelect.js";
import GnTextarea from "./components/GnTextarea.js";
+import GnActionCard from "./components/GnActionCard.js";
+import GnActionList from "./components/GnActionList.js";
+import GnCopyButton from "./components/GnCopyButton.js";
+import GnRange from "./components/GnRange.js";
+import GnTagInput from "./components/GnTagInput.js";
import GnCard from "./components/GnCard.js";
import GnModal from "./components/GnModal.js";
import GnConfirmDialog from "./components/GnConfirmDialog.js";
@@ -73,6 +78,11 @@
GnSwitch,
GnSelect,
GnTextarea,
+ GnActionCard,
+ GnActionList,
+ GnCopyButton,
+ GnRange,
+ GnTagInput,
GnCard,
GnModal,
GnConfirmDialog,
diff --git a/tests/demo-screenshot.spec.js b/tests/demo-screenshot.spec.js
index 97aff12..a154514 100644
--- a/tests/demo-screenshot.spec.js
+++ b/tests/demo-screenshot.spec.js
@@ -15,6 +15,7 @@
{ name: "tables", label: "Tables" },
{ name: "file-upload", label: "File Upload" },
{ name: "advanced-select", label: "Advanced Select" },
+ { name: "lists", label: "Lists" },
{ name: "description-list", label: "Description List" },
{ name: "page-header", label: "Page Header" },
{ name: "backgrounds", label: "Backgrounds" },