Skip to content

GithubWebsite

Building a Reusable Rating Component - Architecture, Testing, and Styling

Vue.js, Component Development, Testing, CSS Modules, Accessibility, TDD3 min read

Building a Reusable Rating Component

Rating components are ubiquitous in modern web applications - from product reviews to user feedback systems. While they might seem straightforward on the surface, building a truly reusable, accessible, and flexible rating component involves careful consideration of architecture, testing, and styling approaches.

In this post, I'll walk you through how I built a comprehensive rating component that handles multiple use cases, maintains accessibility standards, and provides a robust testing foundation.

The Challenge

The goal was to create a rating component that could:

  • Display both single and multi-star ratings
  • Support various sizes and display formats
  • Handle internationalization
  • Maintain accessibility standards
  • Be thoroughly tested across different scenarios
  • Provide a clean, maintainable codebase

Component Architecture Decisions

Vue.js Single File Component Structure

I chose to build this as a Vue.js Single File Component (SFC), which allows for co-location of template, script, and styles. This approach provides excellent developer experience and keeps related code together.

The main component acts as a controller that:

  1. Validates and normalizes props
  2. Determines which rating variant to render
  3. Handles internationalization
  4. Manages accessibility attributes
1<template>
2 <div
3 :class="[
4 $style['c-rating'],
5 { [$style['c-rating--alignLeft']]: shouldAlignRatingTextLeft }
6 ]"
7 :data-test-id="`${getRatingVariant}-component`">
8
9 <div :class="$style['c-rating-stars']">
10 <component
11 :is="getRatingVariant"
12 :max-star-rating="maxStarRating"
13 :star-rating-size="starRatingSize"
14 :star-rating="validatedStarRating" />
15
16 <span
17 v-if="hasRatingAvailable"
18 data-test-id="c-rating-description"
19 class="is-visuallyHidden">
20 {{ getRatingDescription }}
21 </span>
22 </div>
23
24 <span
25 v-if="ratingDisplayType"
26 data-test-id="c-rating-displayType"
27 :class="ratingMessageClasses">
28 {{ getRatingDisplayFormat }}
29 </span>
30 </div>
31</template>

Prop Validation and Constants

One of the key architectural decisions was implementing robust prop validation using constants. This approach ensures type safety and prevents invalid configurations:

1const VALID_STAR_RATING_SIZES = ['xsmall', 'small', 'medium', 'large'];
2const VALID_STAR_RATING_DISPLAY_TYPE = ['short', 'medium', 'long'];
3const VALID_STAR_FONT_SIZES = ['default', 'large'];
4
5export default {
6 props: {
7 starRating: {
8 type: Number,
9 required: true,
10 validator: value => Number.isInteger(value) && value >= 0
11 },
12 maxStarRating: {
13 type: Number,
14 default: 5,
15 validator: value => Number.isInteger(value) && value > 0
16 },
17 starRatingSize: {
18 type: String,
19 default: 'small',
20 validator: value => VALID_STAR_RATING_SIZES.includes(value)
21 },
22 // ... more props with validation
23 }
24}

Component Composition Pattern

Rather than creating a monolithic component, I used a composition pattern with separate components for single-star and multi-star variants. This allows for:

  • Better separation of concerns
  • Easier testing of individual variants
  • More flexible rendering logic
1computed: {
2 getRatingVariant() {
3 return this.isSingleStarVariant
4 ? 'rating-single-star'
5 : 'rating-multi-star';
6 }
7}

Smart Defaults and Validation

The component includes intelligent validation that logs warnings for invalid configurations while gracefully handling edge cases:

1computed: {
2 validatedStarRating() {
3 const validate = this.starRating >= 0 && this.starRating <= this.maxStarRating;
4
5 if (validate) {
6 return this.starRating;
7 }
8
9 this.$log.warn(
10 `The star rating should be between 0 and ${this.maxStarRating} but it was ${this.starRating}`
11 );
12 return 0;
13 }
14}

Comprehensive Testing Strategy

Testing was a crucial part of the development process. I implemented multiple layers of testing to ensure reliability and catch regressions early.

Unit Testing with Jest

The unit tests focus on component logic, prop validation, and computed properties:

1describe('Rating Component', () => {
2 it('should validate star rating bounds', () => {
3 const wrapper = mount(Rating, {
4 props: {
5 starRating: 6,
6 maxStarRating: 5
7 }
8 });
9
10 expect(wrapper.vm.validatedStarRating).toBe(0);
11 });
12
13 it('should return correct rating variant', () => {
14 const wrapper = mount(Rating, {
15 props: {
16 starRating: 4,
17 isSingleStarVariant: true
18 }
19 });
20
21 expect(wrapper.vm.getRatingVariant).toBe('rating-single-star');
22 });
23});

Component Testing

Component tests verify the integration between the main component and its sub-components:

1describe('Rating Component Integration', () => {
2 it('should render multi-star variant by default', () => {
3 const wrapper = mount(Rating, {
4 props: { starRating: 3 }
5 });
6
7 expect(wrapper.findComponent({ name: 'RatingMultiStar' })).toBeTruthy();
8 });
9
10 it('should display rating description for accessibility', () => {
11 const wrapper = mount(Rating, {
12 props: {
13 starRating: 4,
14 maxStarRating: 5,
15 reviewCount: 100
16 }
17 });
18
19 const description = wrapper.find('[data-test-id="c-rating-description"]');
20 expect(description.exists()).toBe(true);
21 expect(description.classes()).toContain('is-visuallyHidden');
22 });
23});

Accessibility Testing

Accessibility is tested both automatically and manually:

1describe('Rating Accessibility', () => {
2 it('should include screen reader text', () => {
3 const wrapper = mount(Rating, {
4 props: {
5 starRating: 3,
6 maxStarRating: 5,
7 reviewCount: 50
8 }
9 });
10
11 const hiddenText = wrapper.find('.is-visuallyHidden');
12 expect(hiddenText.exists()).toBe(true);
13 });
14
15 it('should have proper data attributes for testing', () => {
16 const wrapper = mount(Rating, {
17 props: { starRating: 4 }
18 });
19
20 expect(wrapper.attributes('data-test-id')).toContain('component');
21 });
22});

Visual Regression Testing

For visual consistency, the component includes visual regression tests using tools like Percy or Chromatic:

1describe('Rating Visual Tests', () => {
2 ['xsmall', 'small', 'medium', 'large'].forEach(size => {
3 it(`should render ${size} size correctly`, () => {
4 cy.mount(Rating, {
5 props: {
6 starRating: 3,
7 starRatingSize: size
8 }
9 });
10
11 cy.percySnapshot(`Rating - ${size} size`);
12 });
13 });
14});

Styling Architecture

CSS Modules Approach

I used CSS Modules for component styling, which provides:

  • Scoped styles that prevent conflicts
  • Better maintainability
  • Clear dependency tracking
1.c-rating {
2 display: flex;
3 align-items: center;
4
5 &--alignLeft {
6 flex-direction: row-reverse;
7 }
8}
9
10.c-rating-stars {
11 display: flex;
12 align-items: center;
13}
14
15.c-rating-message {
16 display: flex;
17 align-items: center;
18 font-weight: f.$font-weight-bold;
19 margin-left: f.spacing(a);
20
21 .c-rating--alignLeft & {
22 margin-left: 0;
23 margin-right: f.spacing(b);
24 }
25
26 &--default {
27 @include f.font-size('body-s');
28 }
29
30 &--large {
31 @include f.font-size('heading-xl');
32 }
33}

Design System Integration

The component integrates with a design system using SCSS mixins and functions:

1@use '@design-system/tokens' as tokens;
2
3.c-rating-message {
4 font-weight: tokens.$font-weight-bold;
5
6 &--default {
7 @include tokens.font-size('body-s');
8 }
9
10 &--large {
11 @include tokens.font-size('heading-xl');
12 }
13}

Rating Calculation and Visual Display

One of the most crucial aspects of the rating component is how it calculates and visually represents partial ratings. The component supports both whole and fractional star ratings through a sophisticated CSS-based approach.

Star Fill Calculation

The rating calculation happens in multiple layers:

  1. Rating Normalization: First, we normalize the rating value to ensure it's within valid bounds
  2. Percentage Calculation: Convert the rating to a percentage for each star
  3. CSS Custom Properties: Use CSS variables to control the fill amount
1computed: {
2 normalizedRating() {
3 return Math.max(0, Math.min(this.starRating, this.maxStarRating));
4 },
5
6 starFillData() {
7 const rating = this.normalizedRating;
8 const stars = [];
9
10 for (let i = 1; i <= this.maxStarRating; i++) {
11 if (rating >= i) {
12 // Full star
13 stars.push({ fillPercentage: 100, isFilled: true });
14 } else if (rating > i - 1) {
15 // Partial star
16 const fillPercentage = ((rating - (i - 1)) * 100);
17 stars.push({ fillPercentage, isFilled: false });
18 } else {
19 // Empty star
20 stars.push({ fillPercentage: 0, isFilled: false });
21 }
22 }
23
24 return stars;
25 }
26}

CSS-Based Star Rendering

The visual representation uses CSS mask properties and gradients to create precise star fills:

1.c-rating-star {
2 position: relative;
3 display: inline-block;
4 width: var(--star-size);
5 height: var(--star-size);
6
7 &::before {
8 content: '';
9 position: absolute;
10 top: 0;
11 left: 0;
12 width: 100%;
13 height: 100%;
14 background-color: var(--star-empty-color);
15 mask: url('./star-outline.svg') no-repeat center;
16 mask-size: contain;
17 }
18
19 &::after {
20 content: '';
21 position: absolute;
22 top: 0;
23 left: 0;
24 width: var(--fill-percentage, 0%);
25 height: 100%;
26 background-color: var(--star-filled-color);
27 mask: url('./star-filled.svg') no-repeat center;
28 mask-size: contain;
29 transition: width 0.2s ease-in-out;
30 }
31}

Dynamic Fill Percentage

Each star receives a CSS custom property that controls its fill amount:

1<template>
2 <div class="c-rating-stars">
3 <span
4 v-for="(star, index) in starFillData"
5 :key="`star-${index}`"
6 :class="$style['c-rating-star']"
7 :style="{
8 '--fill-percentage': `${star.fillPercentage}%`,
9 '--star-size': getStarSize(starRatingSize)
10 }"
11 :aria-hidden="true">
12 </span>
13 </div>
14</template>

Size Calculations

The component supports multiple star sizes through a size mapping system:

1methods: {
2 getStarSize(size) {
3 const sizeMap = {
4 'xsmall': '16px',
5 'small': '20px',
6 'medium': '24px',
7 'large': '32px'
8 };
9
10 return sizeMap[size] || sizeMap.small;
11 }
12}

Advanced Visual Features

For more sophisticated visual effects, the component includes hover states and animation:

1.c-rating-star {
2 cursor: pointer;
3 transition: transform 0.1s ease-in-out;
4
5 &:hover {
6 transform: scale(1.1);
7 }
8
9 &--interactive {
10 &:hover::after {
11 background-color: var(--star-hover-color);
12 }
13 }
14
15 // Staggered animation for initial load
16 @for $i from 1 through 5 {
17 &:nth-child(#{$i}) {
18 animation-delay: #{($i - 1) * 0.1s};
19 }
20 }
21}
22
23@keyframes star-fill {
24 0% {
25 width: 0%;
26 }
27 100% {
28 width: var(--fill-percentage);
29 }
30}

Color Theme Support

The component supports different color themes through CSS custom properties:

1.c-rating {
2 --star-empty-color: #e0e0e0;
3 --star-filled-color: #ffc107;
4 --star-hover-color: #ff9800;
5
6 &--theme-success {
7 --star-filled-color: #4caf50;
8 }
9
10 &--theme-warning {
11 --star-filled-color: #ff9800;
12 }
13
14 &--theme-danger {
15 --star-filled-color: #f44336;
16 }
17}

This approach provides several benefits:

  1. Precise Control: Exact percentage fills for any rating value
  2. Performance: Uses CSS for animations rather than JavaScript
  3. Accessibility: Maintains semantic meaning while providing visual feedback
  4. Flexibility: Easy to customize colors and sizes through CSS variables
  5. Smooth Animations: CSS transitions provide smooth visual feedback

Responsive Design Considerations

The component handles different screen sizes gracefully:

1.c-rating {
2 @media (max-width: tokens.$breakpoint-mobile) {
3 flex-direction: column;
4 align-items: flex-start;
5
6 .c-rating-message {
7 margin-left: 0;
8 margin-top: tokens.spacing(xs);
9 }
10 }
11}

Internationalization Support

The component includes comprehensive i18n support:

1computed: {
2 getRatingDescription() {
3 if (this.locale) {
4 return this.starRating === 1
5 ? this.$tc('ratings.starsDescription', 1, {
6 rating: this.starRating,
7 maxStarRating: this.maxStarRating
8 })
9 : this.$tc('ratings.starsDescription', 2, {
10 rating: this.starRating,
11 maxStarRating: this.maxStarRating
12 });
13 }
14 return '';
15 },
16
17 getRatingDisplayFormat() {
18 if (!this.locale) return '';
19
20 if (!this.hasRatingAvailable) {
21 return this.$t('ratings.ratingDisplayType.noRating');
22 }
23
24 return this.$t(`ratings.ratingDisplayType.${this.ratingDisplayType}`, {
25 rating: this.starRating,
26 maxStarRating: this.maxStarRating,
27 reviewCount: this.reviewCount
28 });
29 }
30}

Performance Optimizations

Several performance considerations were built into the component:

Computed Properties for Expensive Operations

1computed: {
2 ratingMessageClasses() {
3 return [
4 this.$style['c-rating-message'],
5 this.$style[`c-rating-message--${this.ratingFontSize}`]
6 ];
7 },
8
9 hasRatingAvailable() {
10 return this.reviewCount > 0 &&
11 this.starRating > 0 &&
12 this.starRating <= this.maxStarRating;
13 }
14}

Lazy Loading for Large Applications

The component supports dynamic imports for code splitting:

1// In parent component
2export default {
3 components: {
4 VRating: () => import('@/components/VRating')
5 }
6}

Key Takeaways and Best Practices

Building this rating component taught me several important lessons about creating reusable UI components:

1. Design for Flexibility

  • Use props with sensible defaults
  • Implement comprehensive validation
  • Support multiple use cases without bloating the API

2. Test at Multiple Levels

  • Unit tests for logic and edge cases
  • Component tests for integration
  • Visual tests for UI consistency
  • Accessibility tests for inclusive design

3. Maintain Clear Architecture

  • Separate concerns using composition
  • Use constants for validation
  • Keep computed properties focused and pure

4. Consider Performance Early

  • Use computed properties for expensive operations
  • Implement lazy loading where appropriate
  • Minimize re-renders with smart caching

5. Accessibility is Non-Negotiable

  • Include screen reader support from the start
  • Test with actual assistive technologies
  • Follow WCAG guidelines consistently

Conclusion

Creating a robust, reusable rating component requires careful consideration of architecture, testing, styling, and accessibility. While the end result might look simple, the underlying implementation involves many thoughtful decisions that contribute to its reliability and maintainability.

The key is to start with a solid foundation - clear prop definitions, comprehensive testing, and accessible markup - then build up the features incrementally. This approach ensures that each addition strengthens rather than complicates the component.

Whether you're building a rating component or any other reusable UI element, these patterns and practices will help you create components that stand the test of time and serve your users well.


© 2025. KR All rights reserved.
Kevin Rodrigues