Skip to main content

Overview

Kolibri’s frontend is built with Vue.js 2.7 and follows the Composition API pattern. This guide covers the essential patterns, components, and conventions you need to know.
Critical: Always use the Composition API (setup()) for new components. Do not use the Options API (data(), computed:, methods:).

Frontend Stack

  • Framework: Vue.js 2.7 with Composition API
  • State Management: Composables (Vuex is deprecated)
  • Styling: SCSS with RTLCSS for RTL support
  • Build Tool: Webpack
  • Design System: Kolibri Design System (KDS)
  • Testing: Jest + Vue Testing Library

Component Development

Component Reuse Hierarchy

Before creating a new component, always search for existing ones in this order:
1

Kolibri Design System (KDS)

Check the Design System catalog first. KDS provides KButton, KTextbox, KSelect, KModal, KCircularLoader, KIcon, and many more.
2

Core Kolibri Components

Browse packages/kolibri/components/ for components like CoreTable, AuthMessage, BottomAppBar, AppBar, etc.
3

Kolibri-Common Components

Check packages/kolibri-common/components/ for shared components like AccordionContainer, BaseToolbar, MetadataChips.
4

Create New Component

Only create a new component if none of the above provide what you need.
If an existing component does 80% of what you need, wrap it - don’t rewrite it.

Component Structure (Composition API)

All new Vue components must use the setup() function:
MyComponent.vue
<template>
  <div :style="{ backgroundColor: $themeTokens.surface }">
    <h1>{{ title$() }}</h1>
    <p>{{ message }}</p>
    <KButton @click="handleClick">
      {{ buttonLabel$() }}
    </KButton>
  </div>
</template>

<script>
import { ref, computed } from 'vue';
import { createTranslator } from 'kolibri/utils/i18n';
import useUser from 'kolibri/composables/useUser';

const strings = createTranslator('MyComponentStrings', {
  title: {
    message: 'Welcome to Kolibri',
    context: 'Page heading',
  },
  buttonLabel: {
    message: 'Get started',
    context: 'Button to begin',
  },
});

export default {
  name: 'MyComponent',
  setup() {
    // Composables
    const { isLearner } = useUser();

    // Reactive state
    const message = ref('Hello');

    // Computed properties
    const displayMessage = computed(() => {
      return isLearner.value ? `${message.value}, learner!` : message.value;
    });

    // Methods
    function handleClick() {
      message.value = 'Button clicked!';
    }

    // Expose translations
    const { title$, buttonLabel$ } = strings;

    return {
      // Reactive refs
      message,
      displayMessage,
      // Methods
      handleClick,
      // Translations
      title$,
      buttonLabel$,
    };
  },
};
</script>

<style lang="scss" scoped>
h1 {
  font-size: 24px;
  margin-bottom: 16px;
}

p {
  color: $text-color;
  padding-left: 8px; // Auto-flipped to padding-right in RTL
}
</style>
Component name must match the filename. Use PascalCase for both.

State Management with Composables

Composables are the preferred way to share state and logic. Vuex is deprecated.

Using an Existing Composable

import { computed } from 'vue';
import useUser from 'kolibri/composables/useUser';
import useChannels from 'kolibri-common/composables/useChannels';

export default {
  name: 'ChannelList',
  setup() {
    const { isAdmin } = useUser();
    const { channelsMap, fetchChannels } = useChannels();

    const canManageChannels = computed(() => isAdmin.value);

    // Fetch channels on mount
    fetchChannels();

    return {
      channelsMap,
      canManageChannels,
    };
  },
};

Creating a Composable

Composables follow the use* naming convention:
useCounter.js
import { ref, computed } from 'vue';

/**
 * A composable for managing a counter
 */
export default function useCounter(initialValue = 0) {
  // Reactive state
  const count = ref(initialValue);

  // Computed properties
  const doubled = computed(() => count.value * 2);

  // Methods
  function increment() {
    count.value++;
  }

  function decrement() {
    count.value--;
  }

  function reset() {
    count.value = initialValue;
  }

  // Return public API
  return {
    count,
    doubled,
    increment,
    decrement,
    reset,
  };
}

Shared State Pattern

For globally-shared state, define refs at the module level:
useChannels.js
import { ref, reactive } from 'vue';
import { get, set } from '@vueuse/core';
import ChannelResource from 'kolibri-common/apiResources/ChannelResource';

// Module-level state - shared across all consumers
const channelsMap = reactive({});
const localChannelsCache = ref([]);

function fetchChannels(params) {
  return ChannelResource.list({ available: true, ...params }).then(channels => {
    for (const channel of channels) {
      set(channelsMap, channel.id, channel);
    }
    if (Object.keys(params).length === 0) {
      set(localChannelsCache, channels);
    }
    return channels;
  });
}

export default function useChannels() {
  return {
    channelsMap,       // Shared state
    localChannelsCache, // Shared state
    fetchChannels,
  };
}
Use module-level state sparingly. Only use it when multiple components truly need to access the same data.
See the Composables guide for more patterns.

Styling and Theming

Always Use Theme Tokens

Never use hard-coded color values. Always use $themeTokens and $themePalette.
<template>
  <div :style="{
    color: $themeTokens.text,
    backgroundColor: $themeTokens.surface
  }">
    <p :style="{ color: $themeTokens.annotation }">
      Secondary text
    </p>
  </div>
</template>
Common theme tokens:
  • $themeTokens.text - Primary text color
  • $themeTokens.annotation - Secondary text color
  • $themeTokens.surface - Surface background
  • $themeTokens.primary - Primary brand color
  • $themeTokens.error - Error state color
For dynamic styles in computed properties, use $computedClass.

Style Blocks, Not Inline Styles

Use <style> blocks for non-dynamic styles. RTL support (RTLCSS) cannot flip inline styles.
<!-- ❌ Wrong: Inline styles don't flip for RTL -->
<template>
  <div style="padding-left: 16px;">
    Content
  </div>
</template>

<!-- ✅ Correct: Style blocks auto-flip for RTL -->
<template>
  <div class="content">
    Content
  </div>
</template>

<style scoped>
.content {
  padding-left: 16px; /* Auto-flips to padding-right in RTL */
}
</style>
For dynamic directional styles, use the isRtl property:
setup() {
  function scrollForward() {
    if (this.isRtl) {
      scrollLeft();
    } else {
      scrollRight();
    }
  }
}

Responsive Design

Do not use CSS @media queries. Use the responsive-window or responsive-element system.
Kolibri runs on varied screen sizes including Android devices. The responsive system provides breakpoints:
import useKResponsiveWindow from 'kolibri-common/composables/useKResponsiveWindow';

export default {
  setup() {
    const { windowIsSmall, windowBreakpoint } = useKResponsiveWindow();

    return {
      windowIsSmall,
      windowBreakpoint,
    };
  },
};

API Calls with Resources

Use Resource classes for all API calls. Never use raw fetch or axios.

Define API Resources

Create resources in apiResources.js:
apiResources.js
import { Resource } from 'kolibri/apiResource';

/**
 * Gets Lessons assigned to the learner
 */
export const LearnerLessonResource = new Resource({
  name: 'learnerlesson',
  namespace: 'kolibri.plugins.learn',
});

export const LearnerCourseResource = new Resource({
  name: 'learnercourse',
  namespace: 'kolibri.plugins.learn',
  // Custom method
  async getResumeData(id) {
    const response = await this.accessDetailEndpoint('get', 'resume', id);
    return response.data;
  },
});

Use Resources in Components

import { ref } from 'vue';
import { LearnerLessonResource } from '../apiResources';

export default {
  setup() {
    const lessons = ref([]);
    const loading = ref(false);

    async function loadLessons() {
      loading.value = true;
      try {
        lessons.value = await LearnerLessonResource.fetchCollection();
      } catch (error) {
        console.error('Failed to load lessons:', error);
      } finally {
        loading.value = false;
      }
    }

    return {
      lessons,
      loading,
      loadLessons,
    };
  },
};

Internationalization (i18n)

All user-visible text must be internationalized. Never hard-code strings in templates.

Using createTranslator

import { createTranslator } from 'kolibri/utils/i18n';

const strings = createTranslator('QuizStrings', {
  title: {
    message: 'Quiz Results',
    context: 'Page heading',
  },
  score: {
    message: 'You scored {score} out of {total}',
    context: 'Score display',
  },
  questionsLabel: {
    message: '{count, plural, one {# question} other {# questions}}',
    context: 'Number of questions',
  },
});

export default {
  setup() {
    // Destructure with $ suffix for use in templates
    const { title$, score$, questionsLabel$ } = strings;

    return {
      title$,
      score$,
      questionsLabel$,
    };
  },
};
In template:
<template>
  <div>
    <h1>{{ title$() }}</h1>
    <p>{{ score$({ score: 8, total: 10 }) }}</p>
    <p>{{ questionsLabel$({ count: 5 }) }}</p>
  </div>
</template>

ICU Message Syntax

Use ICU syntax for plurals, variables, and formatting:
{
  itemCount: {
    message: '{count, plural, one {# item} other {# items}}',
    context: 'Number of items',
  },
  greeting: {
    message: 'Hello, {name}!',
    context: 'User greeting',
  },
}
See the Internationalization guide for complete details.

Icons

Always use the KIcon component from the Design System:
<template>
  <KIcon icon="add" />
  <KIcon icon="forward" /> <!-- Auto-flips in RTL -->
  <KIconButton icon="close" @click="handleClose" />
</template>
Browse available icons at the Design System Icons page.

Common Patterns

Loading States

<template>
  <div>
    <KCircularLoader v-if="loading" />
    <div v-else>
      <p v-for="item in items" :key="item.id">
        {{ item.name }}
      </p>
    </div>
  </div>
</template>

<script>
import { ref } from 'vue';

export default {
  setup() {
    const loading = ref(true);
    const items = ref([]);

    async function loadData() {
      loading.value = true;
      try {
        items.value = await fetchItems();
      } finally {
        loading.value = false;
      }
    }

    return { loading, items, loadData };
  },
};
</script>

Forms with Validation

<template>
  <form @submit.prevent="handleSubmit">
    <KTextbox
      v-model="username"
      :label="usernameLabel$()"
      :invalid="usernameError"
      :invalidText="usernameError"
    />
    <KButton type="submit" :disabled="!isValid">
      {{ submitLabel$() }}
    </KButton>
  </form>
</template>

<script>
import { ref, computed } from 'vue';

export default {
  setup() {
    const username = ref('');
    const usernameError = ref('');

    const isValid = computed(() => {
      return username.value.length >= 3;
    });

    function handleSubmit() {
      if (!isValid.value) {
        usernameError.value = 'Username must be at least 3 characters';
        return;
      }
      // Submit form
    }

    return {
      username,
      usernameError,
      isValid,
      handleSubmit,
    };
  },
};
</script>

Best Practices

New components must use setup(). Do not use Options API (data(), computed:, methods:).
Check KDS, kolibri/components/, and kolibri-common/components/ before creating new components.
Vuex is deprecated. Use composables for state management.
Never hard-code colors. Use $themeTokens and $themePalette.
Use createTranslator for all user-visible strings.
Never use raw fetch or axios. Define resources in apiResources.js.
Put non-dynamic styles in <style> blocks so RTLCSS can flip them for RTL languages.
Both should be PascalCase and identical.

Testing

Write tests for all components. See the Testing guide for details.
MyComponent.spec.js
import { render, screen } from '@testing-library/vue';
import MyComponent from '../MyComponent.vue';

describe('MyComponent', () => {
  it('renders the title', () => {
    render(MyComponent, {
      props: { title: 'Test Title' },
    });

    expect(screen.getByText('Test Title')).toBeTruthy();
  });
});

Next Steps

Kolibri Design System

Explore all available components and patterns

Testing Guide

Learn how to write frontend tests with Jest

Internationalization

Deep dive into i18n workflow and patterns

Backend Development

Understand the Django backend and API patterns

Build docs developers (and LLMs) love