Frontend Development 10 min read

Implementing a Snake‑Shaped Step Progression Component in Vue (CustomStep V1 & V2)

This article explains how to build a custom step‑progression UI component in Vue, first using a simple flex layout and then enhancing it with a grid‑based S‑shaped design to support flexible step ordering and dynamic status rendering based on backend data.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Implementing a Snake‑Shaped Step Progression Component in Vue (CustomStep V1 & V2)

In modern web applications, step progression components are widely used for multi‑step forms and task flows, and a more intuitive "snake" layout can improve user experience. The author first creates a basic customStep.vue component that displays steps in a single row, highlights completed steps, and emits click events for backend interaction.

The initial version uses a flex container with each step occupying calc(100% / 6) width, and CSS classes such as .step-num-finished and .step-txt-finished to style completed items. The full source code is:

<template>
    <div class="custom-step">
        <div v-for="(item, index) in stepList" :key="index" class="step-item">
            <div class="item-content">
                <div class="step-title" @click="handleStepClick(index)">
                    <div class="step-num" :class="{ 'step-num-finished': item.status === 'finished' }">{{ index + 1 }}</div>
                    <div class="setp-txt" :class="{ 'step-txt-finished': item.status === 'finished' }">{{ item.title }}</div>
                </div>
            </div>
            <div class="split-line" v-if="!item.isLast" :class="{ 'split-line-finished': isFinished(index) }"></div>
        </div>
    </div>
</template>

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

const props = defineProps({
    stepList: { type: Array, default: () => [] }
})

const isFinished = computed(() => index => {
    const prevStep = props.stepList[index]
    const nextStep = props.stepList[index + 1]
    return prevStep.status === 'finished' && nextStep.status === 'finished'
})

const emit = defineEmits(['stepClick'])
const handleStepClick = index => {
    emit('stepClick', index)
}
</script>

<style lang="less" scoped>
.custom-step {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    row-gap: 20px;
    width: 100%;
    padding: 0 30px;
    .step-item {
        width: calc(100% / 6);
        .item-content { display: flex; align-items: center; }
        .step-title { width: 80px; text-align: center; font-weight: 600; color: #9999a6; cursor: pointer; }
        .step-num { box-sizing: content-box; width: 35px; margin: 0 auto; line-height: 35px; font-size: 16px; border: 3px solid #e3e8ec; border-radius: 100%; }
        .setp-txt { margin-top: 10px; }
        .step-num-finished { color: #1abc9c; border: 3px solid #1abc9c; }
        .step-txt-finished { color: #1abc9c; }
        .split-line { width: calc(100% - 80px); height: 3px; margin-top: -25px; background-color: #e3e8ec; border-radius: 5px; }
        .split-line-finished { background-color: #1abc9c; }
    }
}
</style>

While this works for up to six steps per row, the layout breaks when more steps are added because the default left‑to‑right flow creates a visual discontinuity. To achieve a continuous "S"‑shaped path, the author redesigns the component as customStep_.vue , using CSS Grid and pseudo‑elements to draw dashed connectors and arrows that alternate direction on each row.

The second version defines grid variables for column count, line width, spacing, and arrow size, then applies conditional styling with Less mixins to rotate arrows on even rows and hide unnecessary connectors. The complete source code is:

<template>
    <div class="container">
        <div v-for="(item, index) in stepList" :key="index" class="grid-item">
            <div class="step" :class="{ 'step-finished': item.status === 'finished' }" @click="handleStepClick(index)">{{ item.title }}</div>
        </div>
    </div>
</template>

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

const props = defineProps({
    stepList: { type: Array, default: () => [] }
})

const emit = defineEmits(['stepClick'])
const handleStepClick = index => {
    emit('stepClick', index)
}
</script>

<style lang="less" scoped>
@colNum: 6; // steps per row
@colEven: @colNum * 2; // elements in two rows
@lineWidth: 35px; // connector length
@rowDistance: 50px; // row spacing
@colDistance: @lineWidth; // column spacing
@arrowSize: 6px; // arrow size
@stepColor: #9e9e9e; // base color

.container {
    width: 100%;
    display: grid;
    padding: 30px 0;
    grid-template-columns: repeat(@colNum, 1fr);
    gap: @rowDistance @colDistance;
}

.grid-item {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    &::before { /* horizontal dashed line */
        position: absolute;
        content: '';
        right: -@lineWidth;
        width: @lineWidth;
        height: 0;
        border-top: 1px dashed @stepColor;
    }
    &::after { /* right arrow */
        content: '';
        position: absolute;
        right: (-@colDistance / 2);
        transform: translateX(50%);
        border-top: (@arrowSize / 1.4) solid transparent;
        border-left: @arrowSize solid @stepColor;
        border-bottom: (@arrowSize / 1.4) solid transparent;
    }
    &:nth-child(@{colNum}n) {
        &:not(:last-child) {
            .step {
                &::before {
                    content: '';
                    position: absolute;
                    left: 50%;
                    bottom: -(@rowDistance / 2);
                    height: @lineWidth;
                    border-left: 1px dashed @stepColor;
                    transform: translate(-50%, 50%);
                }
                &::after {
                    content: '';
                    position: absolute;
                    left: 50%;
                    bottom: -(@rowDistance / 2);
                    border-top: @arrowSize solid @stepColor;
                    border-left: (@arrowSize / 1.4) solid transparent;
                    border-right: (@arrowSize / 1.4) solid transparent;
                    transform: translate(-50%, 50%);
                }
            }
        }
    }
    each(range(@colEven), {
        &:nth-child(@{colEven}n+@{value}) {
            @isEvenLine: boolean(@value > @colNum);
            @modNum: mod(@value, @colEven);
            & when (@isEvenLine) {
                @transN: (@colNum + 1 + @colEven - @value - @value);
                transform: translateX(calc(@transN * 100% + @transN * @colDistance));
                &::after { transform: translateX(50%) rotate(180deg) !important; }
            }
            & when (@modNum = @colNum), (@modNum = @colNum + 1) {
                &::before, &::after { display: none; }
            }
            & when not (@isEvenLine) {
                &:last-child { &::before, &::after { display: none; } }
            }
        }
    });
}

.step {
    position: relative;
    width: 100px;
    line-height: 40px;
    font-size: 16px;
    text-align: center;
    border-radius: 5px;
    color: #9e9e9e;
    border: 2px solid #9e9e9e;
}

.step-finished {
    background-color: #4caf50;
    color: #fff;
    border: 2px solid #4caf50;
}
</style>

The two implementations are compared side‑by‑side, showing that the grid‑based S‑shaped layout provides a smoother visual flow for any number of steps, and the component now correctly reflects dynamic step statuses received from the backend.

Overall, the article demonstrates a practical approach to building a flexible, data‑driven step progression UI component in Vue, covering both basic styling and advanced grid techniques.

frontendUIJavaScriptVueWeb DevelopmentCSSStep Component
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.