diff --git a/fe/src/components/CreateListModal.vue b/fe/src/components/CreateListModal.vue
index 835beb4..c33d4dd 100644
--- a/fe/src/components/CreateListModal.vue
+++ b/fe/src/components/CreateListModal.vue
@@ -1,54 +1,43 @@
 <template>
-  <div v-if="isOpen" class="modal-backdrop open" @click.self="closeModal">
-    <div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="createListModalTitle">
-      <div class="modal-header">
-        <h3 id="createListModalTitle">Create New List</h3>
-        <button class="close-button" @click="closeModal" aria-label="Close modal">
-          <svg class="icon" aria-hidden="true">
-            <use xlink:href="#icon-close" />
-          </svg>
-        </button>
-      </div>
+  <VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New List">
+    <template #default>
       <form @submit.prevent="onSubmit">
-        <div class="modal-body">
-          <div class="form-group">
-            <label for="listName" class="form-label">List Name</label>
-            <input type="text" id="listName" v-model="listName" class="form-input" required ref="listNameInput" />
-            <p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p>
-          </div>
+        <VFormField label="List Name" :error-message="formErrors.listName">
+          <VInput type="text" v-model="listName" required ref="listNameInput" />
+        </VFormField>
 
-          <div class="form-group">
-            <label for="description" class="form-label">Description</label>
-            <textarea id="description" v-model="description" class="form-input" rows="3"></textarea>
-          </div>
+        <VFormField label="Description">
+          <VTextarea v-model="description" rows="3" />
+        </VFormField>
 
-          <div class="form-group" v-if="groups && groups.length > 0">
-            <label for="selectedGroup" class="form-label">Associate with Group (Optional)</label>
-            <select id="selectedGroup" v-model="selectedGroupId" class="form-input">
-              <option :value="null">None</option>
-              <option v-for="group in groups" :key="group.value" :value="group.value">
-                {{ group.label }}
-              </option>
-            </select>
-          </div>
-        </div>
-        <div class="modal-footer">
-          <button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button>
-          <button type="submit" class="btn btn-primary ml-2" :disabled="loading">
-            <span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
-            Create
-          </button>
-        </div>
+        <VFormField label="Associate with Group (Optional)" v-if="props.groups && props.groups.length > 0">
+          <VSelect v-model="selectedGroupId" :options="groupOptionsForSelect" placeholder="None" />
+        </VFormField>
+        <!-- Form submission is handled by button in footer slot -->
       </form>
-    </div>
-  </div>
+    </template>
+    <template #footer>
+      <VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
+      <VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
+        <VSpinner v-if="loading" size="sm" />
+        Create
+      </VButton>
+    </template>
+  </VModal>
 </template>
 
 <script setup lang="ts">
-import { ref, watch, nextTick } from 'vue';
-import { useVModel, onClickOutside } from '@vueuse/core';
+import { ref, watch, nextTick, computed } from 'vue';
+import { useVModel } from '@vueuse/core'; // onClickOutside removed
 import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct
 import { useNotificationStore } from '@/stores/notifications';
+import VModal from '@/components/valerie/VModal.vue';
+import VFormField from '@/components/valerie/VFormField.vue';
+import VInput from '@/components/valerie/VInput.vue';
+import VTextarea from '@/components/valerie/VTextarea.vue';
+import VSelect from '@/components/valerie/VSelect.vue';
+import VButton from '@/components/valerie/VButton.vue';
+import VSpinner from '@/components/valerie/VSpinner.vue';
 
 const props = defineProps<{
   modelValue: boolean;
@@ -68,27 +57,35 @@ const loading = ref(false);
 const formErrors = ref<{ listName?: string }>({});
 const notificationStore = useNotificationStore();
 
-const listNameInput = ref<HTMLInputElement | null>(null);
-const modalContainerRef = ref<HTMLElement | null>(null); // For onClickOutside
+const listNameInput = ref<InstanceType<typeof VInput> | null>(null);
+// const modalContainerRef = ref<HTMLElement | null>(null); // Removed
+
+const groupOptionsForSelect = computed(() => {
+  const options = props.groups ? props.groups.map(g => ({ label: g.label, value: g.value })) : [];
+  // VSelect expects placeholder to be passed as a prop, not as an option for empty value usually
+  // However, if 'None' is a valid selectable option representing null, this is okay.
+  // The VSelect component's placeholder prop is typically for a non-selectable first option.
+  // Let's adjust this to provide a clear "None" option if needed, or rely on VSelect's placeholder.
+  // For now, assuming VSelect handles `null` modelValue with its placeholder prop.
+  // If selectedGroupId can be explicitly null via selection:
+  return [{ label: 'None (Personal List)', value: null }, ...options];
+});
+
 
 watch(isOpen, (newVal) => {
   if (newVal) {
     // Reset form when opening
     listName.value = '';
     description.value = '';
-    selectedGroupId.value = null;
+    selectedGroupId.value = null; // Default to 'None' or personal list
     formErrors.value = {};
     nextTick(() => {
-      listNameInput.value?.focus();
+      listNameInput.value?.focus?.();
     });
   }
 });
 
-onClickOutside(modalContainerRef, () => {
-  if (isOpen.value) {
-    closeModal();
-  }
-});
+// onClickOutside removed, VModal handles backdrop clicks
 
 const closeModal = () => {
   isOpen.value = false;
diff --git a/fe/src/components/valerie/VHeading.spec.ts b/fe/src/components/valerie/VHeading.spec.ts
new file mode 100644
index 0000000..844a6ef
--- /dev/null
+++ b/fe/src/components/valerie/VHeading.spec.ts
@@ -0,0 +1,65 @@
+import { mount } from '@vue/test-utils';
+import VHeading from './VHeading.vue';
+import { describe, it, expect } from 'vitest';
+
+describe('VHeading.vue', () => {
+  it('renders correct heading tag based on level prop', () => {
+    const wrapperH1 = mount(VHeading, { props: { level: 1, text: 'H1' } });
+    expect(wrapperH1.element.tagName).toBe('H1');
+
+    const wrapperH2 = mount(VHeading, { props: { level: 2, text: 'H2' } });
+    expect(wrapperH2.element.tagName).toBe('H2');
+
+    const wrapperH3 = mount(VHeading, { props: { level: 3, text: 'H3' } });
+    expect(wrapperH3.element.tagName).toBe('H3');
+  });
+
+  it('renders text prop content when no default slot', () => {
+    const headingText = 'My Awesome Heading';
+    const wrapper = mount(VHeading, { props: { level: 1, text: headingText } });
+    expect(wrapper.text()).toBe(headingText);
+  });
+
+  it('renders default slot content instead of text prop', () => {
+    const slotContent = '<em>Custom Slot Heading</em>';
+    const wrapper = mount(VHeading, {
+      props: { level: 2, text: 'Ignored Text Prop' },
+      slots: { default: slotContent },
+    });
+    expect(wrapper.html()).toContain(slotContent);
+    expect(wrapper.text()).not.toBe('Ignored Text Prop'); // Check text() to be sure
+    expect(wrapper.find('em').exists()).toBe(true);
+  });
+
+  it('applies id attribute when id prop is provided', () => {
+    const headingId = 'section-title-1';
+    const wrapper = mount(VHeading, { props: { level: 1, id: headingId } });
+    expect(wrapper.attributes('id')).toBe(headingId);
+  });
+
+  it('does not have an id attribute if id prop is not provided', () => {
+    const wrapper = mount(VHeading, { props: { level: 1 } });
+    expect(wrapper.attributes('id')).toBeUndefined();
+  });
+
+  it('validates level prop correctly', () => {
+    const validator = VHeading.props.level.validator;
+    expect(validator(1)).toBe(true);
+    expect(validator(2)).toBe(true);
+    expect(validator(3)).toBe(true);
+    expect(validator(4)).toBe(false);
+    expect(validator(0)).toBe(false);
+    expect(validator('1')).toBe(false); // Expects a number
+  });
+
+  it('renders an empty heading if text prop is empty and no slot', () => {
+    const wrapper = mount(VHeading, { props: { level: 1, text: '' } });
+    expect(wrapper.text()).toBe('');
+    expect(wrapper.element.children.length).toBe(0); // No child nodes
+  });
+
+  it('renders correctly if text prop is not provided (defaults to empty string)', () => {
+    const wrapper = mount(VHeading, { props: { level: 1 } }); // text prop is optional, defaults to ''
+    expect(wrapper.text()).toBe('');
+  });
+});
diff --git a/fe/src/components/valerie/VHeading.stories.ts b/fe/src/components/valerie/VHeading.stories.ts
new file mode 100644
index 0000000..ea7853a
--- /dev/null
+++ b/fe/src/components/valerie/VHeading.stories.ts
@@ -0,0 +1,100 @@
+import VHeading from './VHeading.vue';
+import VIcon from './VIcon.vue'; // For custom slot content example
+import type { Meta, StoryObj } from '@storybook/vue3';
+
+const meta: Meta<typeof VHeading> = {
+  title: 'Valerie/VHeading',
+  component: VHeading,
+  tags: ['autodocs'],
+  argTypes: {
+    level: {
+      control: { type: 'select' },
+      options: [1, 2, 3],
+      description: 'Determines the heading tag (1 for h1, 2 for h2, 3 for h3).',
+    },
+    text: { control: 'text', description: 'Text content of the heading (ignored if default slot is used).' },
+    id: { control: 'text', description: 'Optional ID for the heading element.' },
+    default: { description: 'Slot for custom heading content (overrides text prop).', table: { disable: true } },
+  },
+  parameters: {
+    docs: {
+      description: {
+        component: 'A dynamic heading component that renders `<h1>`, `<h2>`, or `<h3>` tags based on the `level` prop. It relies on global styles for h1, h2, h3 from `valerie-ui.scss`.',
+      },
+    },
+  },
+};
+
+export default meta;
+type Story = StoryObj<typeof VHeading>;
+
+export const Level1: Story = {
+  args: {
+    level: 1,
+    text: 'This is an H1 Heading',
+    id: 'heading-level-1',
+  },
+};
+
+export const Level2: Story = {
+  args: {
+    level: 2,
+    text: 'This is an H2 Heading',
+  },
+};
+
+export const Level3: Story = {
+  args: {
+    level: 3,
+    text: 'This is an H3 Heading',
+  },
+};
+
+export const WithCustomSlotContent: Story = {
+  render: (args) => ({
+    components: { VHeading, VIcon },
+    setup() {
+      return { args };
+    },
+    template: `
+      <VHeading :level="args.level" :id="args.id">
+        <span>Custom Content with an Icon <VIcon name="alert" size="sm" style="color: #007bff;" /></span>
+      </VHeading>
+    `,
+  }),
+  args: {
+    level: 2,
+    id: 'custom-content-heading',
+    // text prop is ignored when default slot is used
+  },
+  parameters: {
+    docs: {
+      description: {
+        story: 'Demonstrates using the default slot for more complex heading content, such as text with an inline icon. The `text` prop is ignored in this case.',
+      },
+    },
+  },
+};
+
+export const WithId: Story = {
+  args: {
+    level: 3,
+    text: 'Heading with a Specific ID',
+    id: 'my-section-title',
+  },
+};
+
+export const EmptyTextPropAndNoSlot: Story = {
+   args: {
+    level: 2,
+    text: '', // Empty text prop
+    // No default slot content
+  },
+   parameters: {
+    docs: {
+      description: {
+        story: 'Renders an empty heading tag (e.g., `<h2></h2>`) if both the `text` prop is empty and no default slot content is provided.',
+      },
+    },
+  },
+};
diff --git a/fe/src/components/valerie/VHeading.vue b/fe/src/components/valerie/VHeading.vue
new file mode 100644
index 0000000..a9be003
--- /dev/null
+++ b/fe/src/components/valerie/VHeading.vue
@@ -0,0 +1,35 @@
+<template>
+  <component :is="tagName" :id="id">
+    <slot>{{ text }}</slot>
+  </component>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+const props = defineProps({
+  level: {
+    type: Number,
+    required: true,
+    validator: (value: number) => [1, 2, 3].includes(value),
+  },
+  text: {
+    type: String,
+    default: '',
+  },
+  id: {
+    type: String,
+    default: null,
+  },
+});
+
+const tagName = computed(() => {
+  if (props.level === 1) return 'h1';
+  if (props.level === 2) return 'h2';
+  if (props.level === 3) return 'h3';
+  return 'h2'; // Fallback, though validator should prevent this
+});
+
+// No specific SCSS needed here as it relies on global h1, h2, h3 styles
+// from valerie-ui.scss.
+</script>
diff --git a/fe/src/components/valerie/VSpinner.spec.ts b/fe/src/components/valerie/VSpinner.spec.ts
new file mode 100644
index 0000000..8c1225b
--- /dev/null
+++ b/fe/src/components/valerie/VSpinner.spec.ts
@@ -0,0 +1,55 @@
+import { mount } from '@vue/test-utils';
+import VSpinner from './VSpinner.vue';
+import { describe, it, expect } from 'vitest';
+
+describe('VSpinner.vue', () => {
+  it('applies default "md" size (no specific class for md, just .spinner-dots)', () => {
+    const wrapper = mount(VSpinner);
+    expect(wrapper.classes()).toContain('spinner-dots');
+    // Check that it does NOT have sm class unless specified
+    expect(wrapper.classes()).not.toContain('spinner-dots-sm');
+  });
+
+  it('applies .spinner-dots-sm class when size is "sm"', () => {
+    const wrapper = mount(VSpinner, { props: { size: 'sm' } });
+    expect(wrapper.classes()).toContain('spinner-dots'); // Base class
+    expect(wrapper.classes()).toContain('spinner-dots-sm'); // Size specific class
+  });
+
+  it('does not apply .spinner-dots-sm class when size is "md"', () => {
+    const wrapper = mount(VSpinner, { props: { size: 'md' } });
+    expect(wrapper.classes()).toContain('spinner-dots');
+    expect(wrapper.classes()).not.toContain('spinner-dots-sm');
+  });
+
+
+  it('sets aria-label attribute with the label prop value', () => {
+    const labelText = 'Fetching data, please wait...';
+    const wrapper = mount(VSpinner, { props: { label: labelText } });
+    expect(wrapper.attributes('aria-label')).toBe(labelText);
+  });
+
+  it('sets default aria-label "Loading..." if label prop is not provided', () => {
+    const wrapper = mount(VSpinner); // No label prop
+    expect(wrapper.attributes('aria-label')).toBe('Loading...');
+  });
+
+  it('has role="status" attribute', () => {
+    const wrapper = mount(VSpinner);
+    expect(wrapper.attributes('role')).toBe('status');
+  });
+
+  it('renders three <span> elements for the dots', () => {
+    const wrapper = mount(VSpinner);
+    const dotSpans = wrapper.findAll('span');
+    expect(dotSpans.length).toBe(3);
+  });
+
+  it('validates size prop correctly', () => {
+    const validator = VSpinner.props.size.validator;
+    expect(validator('sm')).toBe(true);
+    expect(validator('md')).toBe(true);
+    expect(validator('lg')).toBe(false); // lg is not a valid size
+    expect(validator('')).toBe(false);
+  });
+});
diff --git a/fe/src/components/valerie/VSpinner.stories.ts b/fe/src/components/valerie/VSpinner.stories.ts
new file mode 100644
index 0000000..2203666
--- /dev/null
+++ b/fe/src/components/valerie/VSpinner.stories.ts
@@ -0,0 +1,64 @@
+import VSpinner from './VSpinner.vue';
+import type { Meta, StoryObj } from '@storybook/vue3';
+
+const meta: Meta<typeof VSpinner> = {
+  title: 'Valerie/VSpinner',
+  component: VSpinner,
+  tags: ['autodocs'],
+  argTypes: {
+    size: {
+      control: 'select',
+      options: ['sm', 'md'],
+      description: 'Size of the spinner.',
+    },
+    label: {
+      control: 'text',
+      description: 'Accessible label for the spinner (visually hidden).',
+    },
+  },
+  parameters: {
+    docs: {
+      description: {
+        component: 'A simple animated spinner component to indicate loading states. It uses CSS animations for the dots and provides accessibility attributes.',
+      },
+    },
+    layout: 'centered', // Center the spinner in the story
+  },
+};
+
+export default meta;
+type Story = StoryObj<typeof VSpinner>;
+
+export const DefaultSizeMedium: Story = {
+  args: {
+    size: 'md',
+    label: 'Loading content...',
+  },
+};
+
+export const SmallSize: Story = {
+  args: {
+    size: 'sm',
+    label: 'Processing small task...',
+  },
+};
+
+export const CustomLabel: Story = {
+  args: {
+    size: 'md',
+    label: 'Please wait while data is being fetched.',
+  },
+};
+
+export const OnlySpinnerNoLabelArg: Story = {
+  // The component has a default label "Loading..."
+  args: {
+    size: 'md',
+    // label prop not set, should use default
+  },
+   parameters: {
+    docs: {
+      description: { story: 'Spinner using the default accessible label "Loading..." when the `label` prop is not explicitly provided.' },
+    },
+  },
+};
diff --git a/fe/src/components/valerie/VSpinner.vue b/fe/src/components/valerie/VSpinner.vue
new file mode 100644
index 0000000..99f11c2
--- /dev/null
+++ b/fe/src/components/valerie/VSpinner.vue
@@ -0,0 +1,95 @@
+<template>
+  <div
+    role="status"
+    :aria-label="label"
+    class="spinner-dots"
+    :class="sizeClass"
+  >
+    <span></span>
+    <span></span>
+    <span></span>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+const props = defineProps({
+  size: {
+    type: String, // 'sm', 'md'
+    default: 'md',
+    validator: (value: string) => ['sm', 'md'].includes(value),
+  },
+  label: {
+    type: String,
+    default: 'Loading...',
+  },
+});
+
+const sizeClass = computed(() => {
+  // Based on valerie-ui.scss, 'spinner-dots' is the medium size.
+  // Only 'sm' size needs an additional specific class.
+  return props.size === 'sm' ? 'spinner-dots-sm' : null;
+});
+</script>
+
+<style lang="scss" scoped>
+// Styles for .spinner-dots and .spinner-dots-sm are assumed to be globally available
+// from valerie-ui.scss or a similar imported stylesheet.
+// For completeness in a standalone component context, they would be defined here.
+// Example (from valerie-ui.scss structure):
+
+// .spinner-dots {
+//   display: inline-flex; // Changed from inline-block for better flex alignment if needed
+//   align-items: center; // Align dots vertically if their heights differ (should not with this CSS)
+//   justify-content: space-around; // Distribute dots if container has more space (width affects this)
+//   // Default (medium) size variables from valerie-ui.scss
+//   // --spinner-dot-size: 8px;
+//   // --spinner-spacing: 2px;
+//   // width: calc(var(--spinner-dot-size) * 3 + var(--spinner-spacing) * 2);
+//   // height: var(--spinner-dot-size);
+
+//   span {
+//     display: inline-block;
+//     width: var(--spinner-dot-size, 8px);
+//     height: var(--spinner-dot-size, 8px);
+//     margin: 0 var(--spinner-spacing, 2px); // Replaces justify-content if width is tight
+//     border-radius: 50%;
+//     background-color: var(--spinner-color, #007bff); // Use a CSS variable for color
+//     animation: spinner-dots-bounce 1.4s infinite ease-in-out both;
+
+//     &:first-child { margin-left: 0; }
+//     &:last-child { margin-right: 0; }
+
+//     &:nth-child(1) {
+//       animation-delay: -0.32s;
+//     }
+//     &:nth-child(2) {
+//       animation-delay: -0.16s;
+//     }
+//     // nth-child(3) has no delay by default in the animation
+//   }
+// }
+
+// .spinner-dots-sm {
+//   // Override CSS variables for small size
+//   --spinner-dot-size: 6px;
+//   --spinner-spacing: 1px;
+//   // Width and height will adjust based on the new variable values if .spinner-dots uses them.
+// }
+
+// @keyframes spinner-dots-bounce {
+//   0%, 80%, 100% {
+//     transform: scale(0);
+//   }
+//   40% {
+//     transform: scale(1.0);
+//   }
+// }
+
+// Since this component relies on styles from valerie-ui.scss,
+// ensure that valerie-ui.scss is imported in the application's global styles
+// or in a higher-level component. If these styles are not present globally,
+// the spinner will not render correctly.
+// For Storybook, this means valerie-ui.scss needs to be imported in .storybook/preview.js or similar.
+</style>
diff --git a/fe/src/components/valerie/VTable.spec.ts b/fe/src/components/valerie/VTable.spec.ts
new file mode 100644
index 0000000..3fb07f3
--- /dev/null
+++ b/fe/src/components/valerie/VTable.spec.ts
@@ -0,0 +1,162 @@
+import { mount } from '@vue/test-utils';
+import VTable from './VTable.vue';
+import { describe, it, expect, vi } from 'vitest';
+
+const testHeaders = [
+  { key: 'id', label: 'ID' },
+  { key: 'name', label: 'Name', headerClass: 'name-header', cellClass: 'name-cell' },
+  { key: 'email', label: 'Email Address' },
+];
+
+const testItems = [
+  { id: 1, name: 'Alice', email: 'alice@example.com' },
+  { id: 2, name: 'Bob', email: 'bob@example.com' },
+];
+
+describe('VTable.vue', () => {
+  it('renders headers correctly', () => {
+    const wrapper = mount(VTable, { props: { headers: testHeaders, items: [] } });
+    const thElements = wrapper.findAll('thead th');
+    expect(thElements.length).toBe(testHeaders.length);
+    testHeaders.forEach((header, index) => {
+      expect(thElements[index].text()).toBe(header.label);
+    });
+  });
+
+  it('renders item data correctly', () => {
+    const wrapper = mount(VTable, { props: { headers: testHeaders, items: testItems } });
+    const rows = wrapper.findAll('tbody tr');
+    expect(rows.length).toBe(testItems.length);
+
+    rows.forEach((row, rowIndex) => {
+      const cells = row.findAll('td');
+      expect(cells.length).toBe(testHeaders.length);
+      testHeaders.forEach((header, colIndex) => {
+        expect(cells[colIndex].text()).toBe(String(testItems[rowIndex][header.key]));
+      });
+    });
+  });
+
+  it('applies stickyHeader class to thead', () => {
+    const wrapper = mount(VTable, { props: { headers: [], items: [], stickyHeader: true } });
+    expect(wrapper.find('thead').classes()).toContain('sticky-header');
+  });
+
+  it('applies stickyFooter class to tfoot', () => {
+    const wrapper = mount(VTable, {
+      props: { headers: [], items: [], stickyFooter: true },
+      slots: { footer: '<tr><td>Footer</td></tr>' },
+    });
+    expect(wrapper.find('tfoot').classes()).toContain('sticky-footer');
+  });
+
+  it('does not render tfoot if no footer slot', () => {
+    const wrapper = mount(VTable, { props: { headers: [], items: [] } });
+    expect(wrapper.find('tfoot').exists()).toBe(false);
+  });
+
+  it('renders custom header slot content', () => {
+    const wrapper = mount(VTable, {
+      props: { headers: [{ key: 'name', label: 'Name' }], items: [] },
+      slots: { 'header.name': '<div class="custom-header-slot">Custom Name Header</div>' },
+    });
+    const headerCell = wrapper.find('thead th');
+    expect(headerCell.find('.custom-header-slot').exists()).toBe(true);
+    expect(headerCell.text()).toBe('Custom Name Header');
+  });
+
+  it('renders custom item cell slot content', () => {
+    const wrapper = mount(VTable, {
+      props: { headers: [{ key: 'name', label: 'Name' }], items: [{ name: 'Alice' }] },
+      slots: { 'item.name': '<template #item.name="{ value }"><strong>{{ value.toUpperCase() }}</strong></template>' },
+    });
+    const cell = wrapper.find('tbody td');
+    expect(cell.find('strong').exists()).toBe(true);
+    expect(cell.text()).toBe('ALICE');
+  });
+
+  it('renders custom full item row slot content', () => {
+    const wrapper = mount(VTable, {
+      props: { headers: testHeaders, items: [testItems[0]] },
+      slots: {
+        'item': '<template #item="{ item, rowIndex }"><tr class="custom-row"><td :colspan="3">Custom Row {{ rowIndex }}: {{ item.name }}</td></tr></template>'
+      },
+    });
+    expect(wrapper.find('tbody tr.custom-row').exists()).toBe(true);
+    expect(wrapper.find('tbody td').text()).toBe('Custom Row 0: Alice');
+  });
+
+
+  it('renders empty-state slot when items array is empty', () => {
+    const emptyStateContent = '<div>No items available.</div>';
+    const wrapper = mount(VTable, {
+      props: { headers: testHeaders, items: [] },
+      slots: { 'empty-state': emptyStateContent },
+    });
+    const emptyRow = wrapper.find('tbody tr');
+    expect(emptyRow.exists()).toBe(true);
+    const cell = emptyRow.find('td');
+    expect(cell.exists()).toBe(true);
+    expect(cell.attributes('colspan')).toBe(String(testHeaders.length));
+    expect(cell.html()).toContain(emptyStateContent);
+  });
+
+  it('renders empty-state slot with colspan 1 if headers are also empty', () => {
+    const wrapper = mount(VTable, {
+      props: { headers: [], items: [] }, // No headers
+      slots: { 'empty-state': '<span>Empty</span>' },
+    });
+    const cell = wrapper.find('tbody td');
+    expect(cell.attributes('colspan')).toBe('1');
+  });
+
+
+  it('renders caption from prop', () => {
+    const captionText = 'My Table Caption';
+    const wrapper = mount(VTable, { props: { headers: [], items: [], caption: captionText } });
+    const captionElement = wrapper.find('caption');
+    expect(captionElement.exists()).toBe(true);
+    expect(captionElement.text()).toBe(captionText);
+  });
+
+  it('renders caption from slot (overrides prop)', () => {
+    const slotCaption = '<em>Slot Caption</em>';
+    const wrapper = mount(VTable, {
+      props: { headers: [], items: [], caption: 'Prop Caption Ignored' },
+      slots: { caption: slotCaption },
+    });
+    const captionElement = wrapper.find('caption');
+    expect(captionElement.html()).toContain(slotCaption);
+  });
+
+  it('does not render caption if no prop and no slot', () => {
+     const wrapper = mount(VTable, { props: { headers: [], items: [] } });
+     expect(wrapper.find('caption').exists()).toBe(false);
+  });
+
+  it('applies tableClass to table element', () => {
+    const customClass = 'my-custom-table-class';
+    const wrapper = mount(VTable, { props: { headers: [], items: [], tableClass: customClass } });
+    expect(wrapper.find('table.table').classes()).toContain(customClass);
+  });
+
+  it('applies headerClass to th element', () => {
+    const headerWithClass = [{ key: 'id', label: 'ID', headerClass: 'custom-th-class' }];
+    const wrapper = mount(VTable, { props: { headers: headerWithClass, items: [] } });
+    expect(wrapper.find('thead th').classes()).toContain('custom-th-class');
+  });
+
+  it('applies cellClass to td element', () => {
+    const headerWithCellClass = [{ key: 'name', label: 'Name', cellClass: 'custom-td-class' }];
+    const itemsForCellClass = [{ name: 'Test' }];
+    const wrapper = mount(VTable, { props: { headers: headerWithCellClass, items: itemsForCellClass } });
+    expect(wrapper.find('tbody td').classes()).toContain('custom-td-class');
+  });
+
+  it('renders an empty tbody if items is empty and no empty-state slot', () => {
+    const wrapper = mount(VTable, { props: { headers: testHeaders, items: [] } });
+    const tbody = wrapper.find('tbody');
+    expect(tbody.exists()).toBe(true);
+    expect(tbody.findAll('tr').length).toBe(0); // No rows
+  });
+});
diff --git a/fe/src/components/valerie/VTable.stories.ts b/fe/src/components/valerie/VTable.stories.ts
new file mode 100644
index 0000000..404d8e2
--- /dev/null
+++ b/fe/src/components/valerie/VTable.stories.ts
@@ -0,0 +1,229 @@
+import VTable from './VTable.vue';
+import VBadge from './VBadge.vue'; // For custom cell rendering example
+import VAvatar from './VAvatar.vue'; // For custom cell rendering
+import VIcon from './VIcon.vue';   // For custom header rendering
+import VButton from './VButton.vue'; // For empty state actions
+import type { Meta, StoryObj } from '@storybook/vue3';
+import { ref } from 'vue';
+
+const meta: Meta<typeof VTable> = {
+  title: 'Valerie/VTable',
+  component: VTable,
+  tags: ['autodocs'],
+  argTypes: {
+    headers: { control: 'object', description: 'Array of header objects ({ key, label, ... }).' },
+    items: { control: 'object', description: 'Array of item objects for rows.' },
+    stickyHeader: { control: 'boolean' },
+    stickyFooter: { control: 'boolean' },
+    tableClass: { control: 'text', description: 'Custom class(es) for the table element.' },
+    caption: { control: 'text', description: 'Caption text for the table.' },
+    // Slots are demonstrated in stories
+  },
+  parameters: {
+    docs: {
+      description: {
+        component: 'A table component for displaying tabular data. Supports custom rendering for headers and cells, sticky header/footer, empty state, and more.',
+      },
+    },
+  },
+};
+
+export default meta;
+type Story = StoryObj<typeof VTable>;
+
+const sampleHeaders = [
+  { key: 'id', label: 'ID', sortable: true, headerClass: 'id-header', cellClass: 'id-cell' },
+  { key: 'name', label: 'Name', sortable: true },
+  { key: 'email', label: 'Email Address' },
+  { key: 'status', label: 'Status', cellClass: 'status-cell-shared' },
+  { key: 'actions', label: 'Actions', sortable: false },
+];
+
+const sampleItems = [
+  { id: 1, name: 'Alice Wonderland', email: 'alice@example.com', status: 'Active', role: 'Admin' },
+  { id: 2, name: 'Bob The Builder', email: 'bob@example.com', status: 'Inactive', role: 'Editor' },
+  { id: 3, name: 'Charlie Brown', email: 'charlie@example.com', status: 'Pending', role: 'Viewer' },
+  { id: 4, name: 'Diana Prince', email: 'diana@example.com', status: 'Active', role: 'Admin' },
+];
+
+export const BasicTable: Story = {
+  args: {
+    headers: sampleHeaders.filter(h => h.key !== 'actions'), // Exclude actions for basic
+    items: sampleItems,
+    caption: 'User information list.',
+  },
+};
+
+export const StickyHeader: Story = {
+  args: {
+    ...BasicTable.args,
+    stickyHeader: true,
+    items: [...sampleItems, ...sampleItems, ...sampleItems], // More items to make scroll visible
+  },
+  // Decorator to provide a scrollable container for the story
+  decorators: [() => ({ template: '<div style="height: 200px; overflow-y: auto; border: 1px solid #ccc;"><story/></div>' })],
+};
+
+export const CustomCellRendering: Story = {
+  render: (args) => ({
+    components: { VTable, VBadge, VAvatar },
+    setup() { return { args }; },
+    template: `
+      <VTable :headers="args.headers" :items="args.items" :caption="args.caption">
+        <template #item.name="{ item }">
+          <div style="display: flex; align-items: center;">
+            <VAvatar :initials="item.name.substring(0,1)" size="sm" style="width: 24px; height: 24px; font-size: 0.7em; margin-right: 8px;" />
+            <span>{{ item.name }}</span>
+          </div>
+        </template>
+        <template #item.status="{ value }">
+          <VBadge
+            :text="value"
+            :variant="value === 'Active' ? 'success' : (value === 'Inactive' ? 'neutral' : 'pending')"
+          />
+        </template>
+        <template #item.actions="{ item }">
+          <VButton size="sm" variant="primary" @click="() => alert('Editing item ' + item.id)">Edit</VButton>
+        </template>
+      </VTable>
+    `,
+  }),
+  args: {
+    headers: sampleHeaders,
+    items: sampleItems,
+    caption: 'Table with custom cell rendering for Status and Actions.',
+  },
+};
+
+export const CustomHeaderRendering: Story = {
+  render: (args) => ({
+    components: { VTable, VIcon },
+    setup() { return { args }; },
+    template: `
+      <VTable :headers="args.headers" :items="args.items">
+        <template #header.name="{ header }">
+          {{ header.label }} <VIcon name="alert" size="sm" style="color: blue;" />
+        </template>
+         <template #header.email="{ header }">
+          <i>{{ header.label }} (italic)</i>
+        </template>
+      </VTable>
+    `,
+  }),
+  args: {
+    headers: sampleHeaders.filter(h => h.key !== 'actions'),
+    items: sampleItems.slice(0, 2),
+  },
+};
+
+export const EmptyStateTable: Story = {
+  render: (args) => ({
+    components: { VTable, VButton, VIcon },
+    setup() { return { args }; },
+    template: `
+      <VTable :headers="args.headers" :items="args.items">
+        <template #empty-state>
+          <div style="text-align: center; padding: 2rem;">
+            <VIcon name="search" size="lg" style="margin-bottom: 1rem; color: #6c757d;" />
+            <h3>No Users Found</h3>
+            <p>There are no users matching your current criteria. Try adjusting your search or filters.</p>
+            <VButton variant="primary" @click="() => alert('Add User clicked')">Add New User</VButton>
+          </div>
+        </template>
+      </VTable>
+    `,
+  }),
+  args: {
+    headers: sampleHeaders,
+    items: [], // Empty items array
+  },
+};
+
+export const WithFooter: Story = {
+   render: (args) => ({
+    components: { VTable },
+    setup() { return { args }; },
+    template: `
+      <VTable :headers="args.headers" :items="args.items" :stickyFooter="args.stickyFooter">
+        <template #footer>
+          <tr>
+            <td :colspan="args.headers.length -1" style="text-align: right; font-weight: bold;">Total Users:</td>
+            <td style="font-weight: bold;">{{ args.items.length }}</td>
+          </tr>
+          <tr>
+            <td :colspan="args.headers.length" style="text-align: center; font-size: 0.9em;">
+              End of user list.
+            </td>
+          </tr>
+        </template>
+      </VTable>
+    `,
+  }),
+  args: {
+    headers: sampleHeaders.filter(h => h.key !== 'actions' && h.key !== 'email'), // Simplified headers for footer example
+    items: sampleItems,
+    stickyFooter: false,
+  },
+};
+
+export const StickyHeaderAndFooter: Story = {
+  ...WithFooter, // Reuses render from WithFooter
+  args: {
+    ...WithFooter.args,
+    stickyHeader: true,
+    stickyFooter: true,
+    items: [...sampleItems, ...sampleItems, ...sampleItems], // More items for scrolling
+  },
+  decorators: [() => ({ template: '<div style="height: 250px; overflow-y: auto; border: 1px solid #ccc;"><story/></div>' })],
+};
+
+
+export const WithCustomTableAndCellClasses: Story = {
+  args: {
+    headers: [
+      { key: 'id', label: 'ID', headerClass: 'text-danger font-weight-bold', cellClass: 'text-muted' },
+      { key: 'name', label: 'Name', headerClass: ['bg-light-blue', 'p-2'], cellClass: (item) => ({ 'text-success': item.status === 'Active' }) },
+      { key: 'email', label: 'Email' },
+    ],
+    items: sampleItems.slice(0,2).map(item => ({...item, headerClass:'should-not-apply-here'})), // added dummy prop to item
+    tableClass: 'table-striped table-hover custom-global-table-class', // Example global/utility classes
+    caption: 'Table with custom classes applied via props.',
+  },
+  // For this story to fully work, the specified custom classes (e.g., text-danger, bg-light-blue)
+  // would need to be defined globally or in valerie-ui.scss.
+  // Storybook will render the classes, but their visual effect depends on CSS definitions.
+  parameters: {
+    docs: {
+      description: { story: 'Demonstrates applying custom CSS classes to the table, header cells, and body cells using `tableClass`, `headerClass`, and `cellClass` props. The actual styling effect depends on these classes being defined in your CSS.'}
+    }
+  }
+};
+
+export const FullRowSlot: Story = {
+  render: (args) => ({
+    components: { VTable, VBadge },
+    setup() { return { args }; },
+    template: `
+      <VTable :headers="args.headers" :items="args.items">
+        <template #item="{ item, rowIndex }">
+          <tr :class="rowIndex % 2 === 0 ? 'bg-light-gray' : 'bg-white'">
+            <td colspan="1" style="font-weight:bold;">ROW {{ rowIndex + 1 }}</td>
+            <td colspan="2">
+              <strong>{{ item.name }}</strong> ({{ item.email }}) - Role: {{item.role}}
+            </td>
+            <td><VBadge :text="item.status" :variant="item.status === 'Active' ? 'success' : 'neutral'" /></td>
+          </tr>
+        </template>
+      </VTable>
+    `,
+  }),
+  args: {
+    headers: sampleHeaders.filter(h => h.key !== 'actions'), // Adjust headers as the slot takes full control
+    items: sampleItems,
+  },
+  parameters: {
+    docs: {
+      description: {story: "Demonstrates using the `item` slot to take full control of row rendering. The `headers` prop is still used for `<thead>` generation, but `<tbody>` rows are completely defined by this slot."}
+    }
+  }
+};
diff --git a/fe/src/components/valerie/VTable.vue b/fe/src/components/valerie/VTable.vue
new file mode 100644
index 0000000..3635166
--- /dev/null
+++ b/fe/src/components/valerie/VTable.vue
@@ -0,0 +1,170 @@
+<template>
+  <div class="table-container">
+    <table class="table" :class="tableClass">
+      <caption v-if="$slots.caption || caption">
+        <slot name="caption">{{ caption }}</slot>
+      </caption>
+      <thead :class="{ 'sticky-header': stickyHeader }">
+        <tr>
+          <th
+            v-for="header in headers"
+            :key="header.key"
+            :class="header.headerClass"
+            scope="col"
+          >
+            <slot :name="`header.${header.key}`" :header="header">
+              {{ header.label }}
+            </slot>
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <template v-if="items.length === 0 && $slots['empty-state']">
+          <tr>
+            <td :colspan="headers.length || 1"> {/* Fallback colspan if headers is empty */}
+              <slot name="empty-state"></slot>
+            </td>
+          </tr>
+        </template>
+        <template v-else>
+          <template v-for="(item, rowIndex) in items" :key="rowIndex">
+            <slot name="item" :item="item" :rowIndex="rowIndex">
+              <tr>
+                <td
+                  v-for="header in headers"
+                  :key="header.key"
+                  :class="header.cellClass"
+                >
+                  <slot :name="`item.${header.key}`" :item="item" :value="item[header.key]" :rowIndex="rowIndex">
+                    {{ item[header.key] }}
+                  </slot>
+                </td>
+              </tr>
+            </slot>
+          </template>
+        </template>
+      </tbody>
+      <tfoot v-if="$slots.footer" :class="{ 'sticky-footer': stickyFooter }">
+        <slot name="footer"></slot>
+      </tfoot>
+    </table>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, PropType } from 'vue';
+
+interface TableHeader {
+  key: string;
+  label: string;
+  sortable?: boolean;
+  headerClass?: string | string[] | Record<string, boolean>;
+  cellClass?: string | string[] | Record<string, boolean>;
+}
+
+// Using defineProps with generic type for items is complex.
+// Using `any` for items for now, can be refined if specific item structure is enforced.
+const props = defineProps({
+  headers: {
+    type: Array as PropType<TableHeader[]>,
+    required: true,
+    default: () => [],
+  },
+  items: {
+    type: Array as PropType<any[]>,
+    required: true,
+    default: () => [],
+  },
+  stickyHeader: {
+    type: Boolean,
+    default: false,
+  },
+  stickyFooter: {
+    type: Boolean,
+    default: false,
+  },
+  tableClass: {
+    type: [String, Array, Object] as PropType<string | string[] | Record<string, boolean>>,
+    default: '',
+  },
+  caption: {
+    type: String,
+    default: null,
+  },
+});
+
+// No specific reactive logic needed in setup for this version,
+// but setup script is used for type imports and defineProps.
+</script>
+
+<style lang="scss" scoped>
+// These styles should align with valerie-ui.scss or be defined here.
+// Assuming standard table styling from a global scope or valerie-ui.scss.
+// For demonstration, some basic table styles are included.
+
+.table-container {
+  width: 100%;
+  overflow-x: auto; // Enable horizontal scrolling if table is wider than container
+  // For sticky header/footer to work correctly, the container might need a defined height
+  // or be within a scrollable viewport.
+  // max-height: 500px; // Example max height for sticky demo
+}
+
+.table {
+  width: 100%;
+  border-collapse: collapse; // Standard table practice
+  // Example base styling, should come from valerie-ui.scss ideally
+  font-size: 0.9rem;
+  color: var(--table-text-color, #333);
+  background-color: var(--table-bg-color, #fff);
+
+  caption {
+    padding: 0.5em 0;
+    caption-side: bottom; // Or top, depending on preference/standard
+    font-size: 0.85em;
+    color: var(--table-caption-color, #666);
+    text-align: left;
+  }
+
+  th, td {
+    padding: 0.75em 1em; // Example padding
+    text-align: left;
+    border-bottom: 1px solid var(--table-border-color, #dee2e6);
+  }
+
+  thead th {
+    font-weight: 600; // Bolder for header cells
+    background-color: var(--table-header-bg, #f8f9fa);
+    border-bottom-width: 2px; // Thicker border under header
+  }
+
+  tbody tr:hover {
+    background-color: var(--table-row-hover-bg, #f1f3f5);
+  }
+
+  // Sticky styles
+  .sticky-header th {
+    position: sticky;
+    top: 0;
+    z-index: 10; // Ensure header is above body content during scroll
+    background-color: var(--table-header-sticky-bg, #f0f2f5); // Might need distinct bg
+  }
+
+  .sticky-footer { // Applied to tfoot
+    td, th { // Assuming footer might contain th or td
+      position: sticky;
+      bottom: 0;
+      z-index: 10; // Ensure footer is above body
+      background-color: var(--table-footer-sticky-bg, #f0f2f5);
+    }
+  }
+  // If both stickyHeader and stickyFooter are used, ensure z-indexes are managed.
+  // Also, for sticky to work on thead/tfoot, the table-container needs to be the scrollable element,
+  // or the window itself if the table is large enough.
+}
+
+// Example of custom classes from props (these would be defined by user)
+// .custom-header-class { background-color: lightblue; }
+// .custom-cell-class { font-style: italic; }
+// .custom-table-class { border: 2px solid blue; }
+</style>
diff --git a/fe/src/components/valerie/VTooltip.spec.ts b/fe/src/components/valerie/VTooltip.spec.ts
new file mode 100644
index 0000000..467f9ed
--- /dev/null
+++ b/fe/src/components/valerie/VTooltip.spec.ts
@@ -0,0 +1,103 @@
+import { mount } from '@vue/test-utils';
+import VTooltip from './VTooltip.vue';
+import { describe, it, expect } from 'vitest';
+
+describe('VTooltip.vue', () => {
+  it('renders default slot content (trigger)', () => {
+    const triggerContent = '<button>Hover Me</button>';
+    const wrapper = mount(VTooltip, {
+      props: { text: 'Tooltip text' },
+      slots: { default: triggerContent },
+    });
+    const trigger = wrapper.find('.tooltip-trigger');
+    expect(trigger.exists()).toBe(true);
+    expect(trigger.html()).toContain(triggerContent);
+  });
+
+  it('renders tooltip text with correct text prop', () => {
+    const tipText = 'This is the tooltip content.';
+    const wrapper = mount(VTooltip, {
+      props: { text: tipText },
+      slots: { default: '<span>Trigger</span>' },
+    });
+    const tooltipTextElement = wrapper.find('.tooltip-text');
+    expect(tooltipTextElement.exists()).toBe(true);
+    expect(tooltipTextElement.text()).toBe(tipText);
+  });
+
+  it('applies position-specific class to the root wrapper', () => {
+    const wrapperTop = mount(VTooltip, {
+      props: { text: 'Hi', position: 'top' },
+      slots: { default: 'Trg' },
+    });
+    expect(wrapperTop.find('.tooltip-wrapper').classes()).toContain('tooltip-top');
+
+    const wrapperBottom = mount(VTooltip, {
+      props: { text: 'Hi', position: 'bottom' },
+      slots: { default: 'Trg' },
+    });
+    expect(wrapperBottom.find('.tooltip-wrapper').classes()).toContain('tooltip-bottom');
+
+    const wrapperLeft = mount(VTooltip, {
+      props: { text: 'Hi', position: 'left' },
+      slots: { default: 'Trg' },
+    });
+    expect(wrapperLeft.find('.tooltip-wrapper').classes()).toContain('tooltip-left');
+
+    const wrapperRight = mount(VTooltip, {
+      props: { text: 'Hi', position: 'right' },
+      slots: { default: 'Trg' },
+    });
+    expect(wrapperRight.find('.tooltip-wrapper').classes()).toContain('tooltip-right');
+  });
+
+  it('defaults to "top" position if not specified', () => {
+    const wrapper = mount(VTooltip, {
+      props: { text: 'Default position' },
+      slots: { default: 'Trigger' },
+    });
+    expect(wrapper.find('.tooltip-wrapper').classes()).toContain('tooltip-top');
+  });
+
+
+  it('applies provided id to tooltip-text and aria-describedby to trigger', () => {
+    const customId = 'my-tooltip-123';
+    const wrapper = mount(VTooltip, {
+      props: { text: 'With ID', id: customId },
+      slots: { default: 'Trigger Element' },
+    });
+    expect(wrapper.find('.tooltip-text').attributes('id')).toBe(customId);
+    expect(wrapper.find('.tooltip-trigger').attributes('aria-describedby')).toBe(customId);
+  });
+
+  it('generates a unique id for tooltip-text if id prop is not provided', () => {
+    const wrapper = mount(VTooltip, {
+      props: { text: 'Auto ID' },
+      slots: { default: 'Trigger' },
+    });
+    const tooltipTextElement = wrapper.find('.tooltip-text');
+    const generatedId = tooltipTextElement.attributes('id');
+    expect(generatedId).toMatch(/^v-tooltip-/);
+    expect(wrapper.find('.tooltip-trigger').attributes('aria-describedby')).toBe(generatedId);
+  });
+
+  it('tooltip-text has role="tooltip"', () => {
+    const wrapper = mount(VTooltip, {
+      props: { text: 'Role test' },
+      slots: { default: 'Trigger' },
+    });
+    expect(wrapper.find('.tooltip-text').attributes('role')).toBe('tooltip');
+  });
+
+  it('tooltip-trigger has tabindex="0"', () => {
+    const wrapper = mount(VTooltip, {
+      props: { text: 'Focus test' },
+      slots: { default: '<span>Non-focusable by default</span>' },
+    });
+    expect(wrapper.find('.tooltip-trigger').attributes('tabindex')).toBe('0');
+  });
+
+  // Note: Testing CSS-driven visibility on hover/focus is generally outside the scope of JSDOM unit tests.
+  // These tests would typically be done in an E2E testing environment with a real browser.
+  // We can, however, test that the structure and attributes that enable this CSS are present.
+});
diff --git a/fe/src/components/valerie/VTooltip.stories.ts b/fe/src/components/valerie/VTooltip.stories.ts
new file mode 100644
index 0000000..aa63c47
--- /dev/null
+++ b/fe/src/components/valerie/VTooltip.stories.ts
@@ -0,0 +1,120 @@
+import VTooltip from './VTooltip.vue';
+import VButton from './VButton.vue'; // Example trigger
+import type { Meta, StoryObj } from '@storybook/vue3';
+
+const meta: Meta<typeof VTooltip> = {
+  title: 'Valerie/VTooltip',
+  component: VTooltip,
+  tags: ['autodocs'],
+  argTypes: {
+    text: { control: 'text', description: 'Tooltip text content.' },
+    position: {
+      control: 'select',
+      options: ['top', 'bottom', 'left', 'right'],
+      description: 'Tooltip position relative to the trigger.',
+    },
+    id: { control: 'text', description: 'Optional ID for the tooltip text element (ARIA).' },
+    // Slot
+    default: { description: 'The trigger element for the tooltip.', table: { disable: true } },
+  },
+  parameters: {
+    docs: {
+      description: {
+        component: 'A tooltip component that displays informational text when a trigger element is hovered or focused. Uses CSS for positioning and visibility.',
+      },
+    },
+    // Adding some layout to center stories and provide space for tooltips
+    layout: 'centered',
+  },
+  // Decorator to add some margin around stories so tooltips don't get cut off by viewport
+  decorators: [() => ({ template: '<div style="padding: 50px;"><story/></div>' })],
+};
+
+export default meta;
+type Story = StoryObj<typeof VTooltip>;
+
+export const Top: Story = {
+  render: (args) => ({
+    components: { VTooltip, VButton },
+    setup() { return { args }; },
+    template: `
+      <VTooltip :text="args.text" :position="args.position" :id="args.id">
+        <VButton>Hover or Focus Me (Top)</VButton>
+      </VTooltip>
+    `,
+  }),
+  args: {
+    text: 'This is a tooltip displayed on top.',
+    position: 'top',
+    id: 'tooltip-top-example',
+  },
+};
+
+export const Bottom: Story = {
+  ...Top, // Reuses render function from Top story
+  args: {
+    text: 'Tooltip shown at the bottom.',
+    position: 'bottom',
+    id: 'tooltip-bottom-example',
+  },
+};
+
+export const Left: Story = {
+  ...Top,
+  args: {
+    text: 'This appears to the left.',
+    position: 'left',
+    id: 'tooltip-left-example',
+  },
+};
+
+export const Right: Story = {
+  ...Top,
+  args: {
+    text: 'And this one to the right!',
+    position: 'right',
+    id: 'tooltip-right-example',
+  },
+};
+
+export const OnPlainText: Story = {
+   render: (args) => ({
+    components: { VTooltip },
+    setup() { return { args }; },
+    template: `
+      <p>
+        Some text here, and
+        <VTooltip :text="args.text" :position="args.position">
+          <span style="text-decoration: underline; color: blue;">this part has a tooltip</span>
+        </VTooltip>
+        which shows up on hover or focus.
+      </p>
+    `,
+  }),
+  args: {
+    text: 'Tooltip on a span of text!',
+    position: 'top',
+  },
+};
+
+export const LongTextTooltip: Story = {
+  ...Top,
+  args: {
+    text: 'This is a much longer tooltip text to see how it behaves. It should remain on a single line by default due to white-space: nowrap. If multi-line is needed, CSS for .tooltip-text would need adjustment (e.g., white-space: normal, width/max-width).',
+    position: 'bottom',
+  },
+  parameters: {
+    docs: {
+      description: { story: 'Demonstrates a tooltip with a longer text content. Default styling keeps it on one line.'}
+    }
+  }
+};
+
+export const WithSpecificId: Story = {
+  ...Top,
+  args: {
+    text: 'This tooltip has a specific ID for its text element.',
+    position: 'top',
+    id: 'my-custom-tooltip-id-123',
+  },
+};
diff --git a/fe/src/components/valerie/VTooltip.vue b/fe/src/components/valerie/VTooltip.vue
new file mode 100644
index 0000000..4079e82
--- /dev/null
+++ b/fe/src/components/valerie/VTooltip.vue
@@ -0,0 +1,151 @@
+<template>
+  <div class="tooltip-wrapper" :class="['tooltip-' + position]">
+    <span class="tooltip-trigger" tabindex="0" :aria-describedby="tooltipId">
+      <slot></slot>
+    </span>
+    <span class="tooltip-text" role="tooltip" :id="tooltipId">
+      {{ text }}
+    </span>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+const props = defineProps({
+  text: {
+    type: String,
+    required: true,
+  },
+  position: {
+    type: String, // 'top', 'bottom', 'left', 'right'
+    default: 'top',
+    validator: (value: string) => ['top', 'bottom', 'left', 'right'].includes(value),
+  },
+  id: {
+    type: String,
+    default: null,
+  },
+});
+
+const tooltipId = computed(() => {
+  return props.id || `v-tooltip-${Math.random().toString(36).substring(2, 9)}`;
+});
+
+</script>
+
+<style lang="scss" scoped>
+// These styles should align with valerie-ui.scss's .tooltip definition.
+// For this component, we'll define them here.
+// A .tooltip-wrapper is used instead of .tooltip directly on the trigger's parent
+// to give more flexibility if the trigger is an inline element.
+.tooltip-wrapper {
+  position: relative;
+  display: inline-block; // Or 'block' or 'inline-flex' depending on how it should behave in layout
+}
+
+.tooltip-trigger {
+  // display: inline-block; // Ensure it can have dimensions if it's an inline element like <span>
+  cursor: help; // Or default, depending on trigger type
+  // Ensure trigger is focusable for keyboard accessibility if it's not inherently focusable (e.g. a span)
+  // tabindex="0" is added in the template.
+  &:focus {
+    outline: none; // Or a custom focus style if desired for the trigger itself
+    // When trigger is focused, the tooltip-text should become visible (handled by CSS below)
+  }
+}
+
+.tooltip-text {
+  position: absolute;
+  z-index: 1070; // High z-index to appear above other elements
+  display: block;
+  padding: 0.4em 0.8em;
+  font-size: 0.875rem; // Slightly smaller font for tooltip
+  font-weight: 400;
+  line-height: 1.5;
+  text-align: left;
+  white-space: nowrap; // Tooltips are usually single-line, can be changed if multi-line is needed
+  color: var(--tooltip-text-color, #fff); // Text color
+  background-color: var(--tooltip-bg-color, #343a40); // Background color (dark gray/black)
+  border-radius: 0.25rem; // Rounded corners
+
+  // Visibility: hidden by default, shown on hover/focus of the wrapper/trigger
+  visibility: hidden;
+  opacity: 0;
+  transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
+
+  // Arrow (pseudo-element)
+  &::after {
+    content: "";
+    position: absolute;
+    border-width: 5px; // Size of the arrow
+    border-style: solid;
+  }
+}
+
+// Show tooltip on hover or focus of the wrapper (or trigger)
+.tooltip-wrapper:hover .tooltip-text,
+.tooltip-wrapper:focus-within .tooltip-text, // focus-within for keyboard nav on trigger
+.tooltip-trigger:focus + .tooltip-text { // If trigger is focused directly
+  visibility: visible;
+  opacity: 1;
+}
+
+
+// Positioning
+// TOP
+.tooltip-top .tooltip-text {
+  bottom: 100%; // Position above the trigger
+  left: 50%;
+  transform: translateX(-50%) translateY(-6px); // Center it and add margin from arrow
+
+  &::after {
+    top: 100%; // Arrow at the bottom of the tooltip text pointing down
+    left: 50%;
+    transform: translateX(-50%);
+    border-color: var(--tooltip-bg-color, #343a40) transparent transparent transparent; // Arrow color
+  }
+}
+
+// BOTTOM
+.tooltip-bottom .tooltip-text {
+  top: 100%; // Position below the trigger
+  left: 50%;
+  transform: translateX(-50%) translateY(6px); // Center it and add margin
+
+  &::after {
+    bottom: 100%; // Arrow at the top of the tooltip text pointing up
+    left: 50%;
+    transform: translateX(-50%);
+    border-color: transparent transparent var(--tooltip-bg-color, #343a40) transparent;
+  }
+}
+
+// LEFT
+.tooltip-left .tooltip-text {
+  top: 50%;
+  right: 100%; // Position to the left of the trigger
+  transform: translateY(-50%) translateX(-6px); // Center it and add margin
+
+  &::after {
+    top: 50%;
+    left: 100%; // Arrow at the right of the tooltip text pointing right
+    transform: translateY(-50%);
+    border-color: transparent transparent transparent var(--tooltip-bg-color, #343a40);
+  }
+}
+
+// RIGHT
+.tooltip-right .tooltip-text {
+  top: 50%;
+  left: 100%; // Position to the right of the trigger
+  transform: translateY(-50%) translateX(6px); // Center it and add margin
+
+  &::after {
+    top: 50%;
+    right: 100%; // Arrow at the left of the tooltip text pointing left
+    transform: translateY(-50%);
+    border-color: transparent var(--tooltip-bg-color, #343a40) transparent transparent;
+  }
+}
+</style>
diff --git a/fe/src/pages/GroupsPage.vue b/fe/src/pages/GroupsPage.vue
index b0fb71b..ea0b40b 100644
--- a/fe/src/pages/GroupsPage.vue
+++ b/fe/src/pages/GroupsPage.vue
@@ -2,41 +2,33 @@
   <main class="container page-padding">
     <!-- <h1 class="mb-3">Your Groups</h1> -->
 
-    <div v-if="fetchError" class="alert alert-error mb-3" role="alert">
-      <div class="alert-content">
-        <svg class="icon" aria-hidden="true">
-          <use xlink:href="#icon-alert-triangle" />
-        </svg>
-        {{ fetchError }}
-      </div>
-      <button type="button" class="btn btn-sm btn-danger" @click="fetchGroups">Retry</button>
-    </div>
+    <VAlert v-if="fetchError" type="error" :message="fetchError" class="mb-3" :closable="false">
+      <template #actions>
+        <VButton variant="danger" size="sm" @click="fetchGroups">Retry</VButton>
+      </template>
+    </VAlert>
 
-    <div v-else-if="groups.length === 0" class="card empty-state-card">
-      <svg class="icon icon-lg" aria-hidden="true">
-        <use xlink:href="#icon-clipboard" />
-      </svg>
-      <h3>No Groups Yet!</h3>
-      <p>You are not a member of any groups yet. Create one or join using an invite code.</p>
-      <button class="btn btn-primary mt-2" @click="openCreateGroupDialog">
-        <svg class="icon" aria-hidden="true">
-          <use xlink:href="#icon-plus" />
-        </svg>
-        Create New Group
-      </button>
-    </div>
+    <VCard v-else-if="groups.length === 0"
+           variant="empty-state"
+           empty-icon="clipboard"
+           empty-title="No Groups Yet!"
+           empty-message="You are not a member of any groups yet. Create one or join using an invite code."
+    >
+      <template #empty-actions>
+        <VButton variant="primary" class="mt-2" @click="openCreateGroupDialog" icon-left="plus">
+          Create New Group
+        </VButton>
+      </template>
+    </VCard>
 
     <div v-else class="mb-3">
       <div class="neo-groups-grid">
         <div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)">
           <h1 class="neo-group-header">{{ group.name }}</h1>
           <div class="neo-group-actions">
-            <button class="btn btn-sm btn-secondary" @click.stop="openCreateListDialog(group)">
-              <svg class="icon" aria-hidden="true">
-                <use xlink:href="#icon-plus" />
-              </svg>
+            <VButton size="sm" variant="secondary" @click.stop="openCreateListDialog(group)" icon-left="plus">
               List
-            </button>
+            </VButton>
           </div>
         </div>
         <div class="neo-create-group-card" @click="openCreateGroupDialog">
@@ -56,52 +48,48 @@
         </summary>
         <div class="card-body">
           <form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
-            <div class="form-group flex-grow" style="margin-bottom: 0;">
-              <label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
-              <input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
-                placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" />
-            </div>
-            <button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
-              <span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
+            <VFormField class="flex-grow" :error-message="joinGroupFormError" label="Enter Invite Code" :label-sr-only="true">
+              <VInput
+                type="text"
+                id="joinInviteCodeInput"
+                v-model="inviteCodeToJoin"
+                placeholder="Enter Invite Code"
+                required
+                ref="joinInviteCodeInputRef"
+              />
+            </VFormField>
+            <VButton type="submit" variant="secondary" :disabled="joiningGroup">
+              <VSpinner v-if="joiningGroup" size="sm" />
               Join
-            </button>
+            </VButton>
           </form>
-          <p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
+          <!-- The error message is now handled by VFormField -->
         </div>
       </details>
     </div>
 
     <!-- Create Group Dialog -->
-    <div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
-      <div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true"
-        aria-labelledby="createGroupTitle">
-        <div class="modal-header">
-          <h3 id="createGroupTitle">Create New Group</h3>
-          <button class="close-button" @click="closeCreateGroupDialog" aria-label="Close">
-            <svg class="icon" aria-hidden="true">
-              <use xlink:href="#icon-close" />
-            </svg>
-          </button>
-        </div>
-        <form @submit.prevent="handleCreateGroup">
-          <div class="modal-body">
-            <div class="form-group">
-              <label for="newGroupNameInput" class="form-label">Group Name</label>
-              <input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required
-                ref="newGroupNameInputRef" />
-              <p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
-            </div>
-          </div>
-          <div class="modal-footer">
-            <button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button>
-            <button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup">
-              <span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
-              Create
-            </button>
-          </div>
-        </form>
-      </div>
-    </div>
+    <VModal v-model="showCreateGroupDialog" title="Create New Group" @update:modelValue="val => !val && closeCreateGroupDialog()">
+      <form @submit.prevent="handleCreateGroup">
+        <VFormField label="Group Name" :error-message="createGroupFormError">
+          <VInput
+            type="text"
+            v-model="newGroupName"
+            placeholder="Enter group name"
+            required
+            id="newGroupNameInput"
+            ref="newGroupNameInputRef"
+          />
+        </VFormField>
+        <template #footer>
+          <VButton variant="neutral" @click="closeCreateGroupDialog" type="button">Cancel</VButton>
+          <VButton type="submit" variant="primary" :disabled="creatingGroup">
+            <VSpinner v-if="creatingGroup" size="sm" />
+            Create
+          </VButton>
+        </template>
+      </form>
+    </VModal>
 
     <!-- Create List Modal -->
     <CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" />
@@ -113,9 +101,16 @@ import { ref, onMounted, nextTick } from 'vue';
 import { useRouter } from 'vue-router';
 import { apiClient, API_ENDPOINTS } from '@/config/api';
 import { useStorage } from '@vueuse/core';
-import { onClickOutside } from '@vueuse/core';
+// import { onClickOutside } from '@vueuse/core'; // No longer needed for VModal
 import { useNotificationStore } from '@/stores/notifications';
 import CreateListModal from '@/components/CreateListModal.vue';
+import VModal from '@/components/valerie/VModal.vue';
+import VFormField from '@/components/valerie/VFormField.vue';
+import VInput from '@/components/valerie/VInput.vue';
+import VButton from '@/components/valerie/VButton.vue';
+import VSpinner from '@/components/valerie/VSpinner.vue';
+import VAlert from '@/components/valerie/VAlert.vue';
+import VCard from '@/components/valerie/VCard.vue';
 
 interface Group {
   id: number;
@@ -135,13 +130,13 @@ const fetchError = ref<string | null>(null);
 const showCreateGroupDialog = ref(false);
 const newGroupName = ref('');
 const creatingGroup = ref(false);
-const newGroupNameInputRef = ref<HTMLInputElement | null>(null);
-const createGroupModalRef = ref<HTMLElement | null>(null);
+const newGroupNameInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed type to VInput instance
+// const createGroupModalRef = ref<HTMLElement | null>(null); // No longer needed
 const createGroupFormError = ref<string | null>(null);
 
 const inviteCodeToJoin = ref('');
 const joiningGroup = ref(false);
-const joinInviteCodeInputRef = ref<HTMLInputElement | null>(null);
+const joinInviteCodeInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed type to VInput instance
 const joinGroupFormError = ref<string | null>(null);
 
 const showCreateListModal = ref(false);
@@ -183,7 +178,12 @@ const openCreateGroupDialog = () => {
   createGroupFormError.value = null;
   showCreateGroupDialog.value = true;
   nextTick(() => {
-    newGroupNameInputRef.value?.focus();
+    // Attempt to focus VInput. This assumes VInput exposes a focus method
+    // or internally focuses its input element on a `focus()` call.
+    // If VInput's input element needs to be accessed directly, it might be:
+    // newGroupNameInputRef.value?.$el.querySelector('input')?.focus(); or similar,
+    // but ideally VInput itself handles this.
+    newGroupNameInputRef.value?.focus?.();
   });
 };
 
@@ -191,12 +191,12 @@ const closeCreateGroupDialog = () => {
   showCreateGroupDialog.value = false;
 };
 
-onClickOutside(createGroupModalRef, closeCreateGroupDialog);
+// onClickOutside(createGroupModalRef, closeCreateGroupDialog); // Replaced by VModal's own handling
 
 const handleCreateGroup = async () => {
   if (!newGroupName.value.trim()) {
     createGroupFormError.value = 'Group name is required';
-    newGroupNameInputRef.value?.focus();
+    newGroupNameInputRef.value?.focus?.(); // Use VInput's focus method if available
     return;
   }
   createGroupFormError.value = null;
@@ -229,7 +229,7 @@ const handleCreateGroup = async () => {
 const handleJoinGroup = async () => {
   if (!inviteCodeToJoin.value.trim()) {
     joinGroupFormError.value = 'Invite code is required';
-    joinInviteCodeInputRef.value?.focus();
+    joinInviteCodeInputRef.value?.focus?.(); // Use VInput's focus method if available
     return;
   }
   joinGroupFormError.value = null;
diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue
index 31975f2..e325e10 100644
--- a/fe/src/pages/ListsPage.vue
+++ b/fe/src/pages/ListsPage.vue
@@ -2,30 +2,27 @@
   <main class="container page-padding">
     <!-- <h1 class="mb-3">{{ pageTitle }}</h1> -->
 
-    <div v-if="error" class="alert alert-error mb-3" role="alert">
-      <div class="alert-content">
-        <svg class="icon" aria-hidden="true">
-          <use xlink:href="#icon-alert-triangle" />
-        </svg>
-        {{ error }}
-      </div>
-      <button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
-    </div>
+    <VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
+      <template #actions>
+        <VButton variant="danger" size="sm" @click="fetchListsAndGroups">Retry</VButton>
+      </template>
+    </VAlert>
 
-    <div v-else-if="lists.length === 0" class="card empty-state-card">
-      <svg class="icon icon-lg" aria-hidden="true">
-        <use xlink:href="#icon-clipboard" />
-      </svg>
-      <h3>{{ noListsMessage }}</h3>
-      <p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
-      <p v-else>This group doesn't have any lists yet.</p>
-      <button class="btn btn-primary mt-2" @click="showCreateModal = true">
-        <svg class="icon" aria-hidden="true">
-          <use xlink:href="#icon-plus" />
-        </svg>
-        Create New List
-      </button>
-    </div>
+    <VCard v-else-if="lists.length === 0"
+           variant="empty-state"
+           empty-icon="clipboard"
+           :empty-title="noListsMessage"
+    >
+      <template #default>
+        <p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
+        <p v-else>This group doesn't have any lists yet.</p>
+      </template>
+      <template #empty-actions>
+        <VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
+          Create New List
+        </VButton>
+      </template>
+    </VCard>
 
     <div v-else>
       <div class="neo-lists-grid">
@@ -67,6 +64,10 @@ import { useRoute, useRouter } from 'vue-router';
 import { apiClient, API_ENDPOINTS } from '@/config/api';
 import CreateListModal from '@/components/CreateListModal.vue';
 import { useStorage } from '@vueuse/core';
+import VAlert from '@/components/valerie/VAlert.vue';
+import VCard from '@/components/valerie/VCard.vue';
+import VButton from '@/components/valerie/VButton.vue';
+// VSpinner might not be needed here unless other parts use it directly
 
 interface List {
   id: number;