Frontend Development 18 min read

Custom Avatar 2.0: A Vue3‑based Online Avatar Customization Tool

This article introduces Custom Avatar 2.0, an open‑source web tool built with Vue3, Vite, TypeScript and Fabric.js that lets users upload a portrait, apply themed frames, stickers, and generate shareable posters, detailing the project architecture, new features, code implementation, and deployment instructions.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Custom Avatar 2.0: A Vue3‑based Online Avatar Customization Tool

Preface

If you want to see the visual effect or customize a Chinese New Year avatar, go directly to the Effect section; if you want to understand the principle and implementation ideas of the Custom Avatar 2.0 tool, please read on as the article contains many code snippets.

Online Customization

🚀🚀🚀 Custom Avatar entry and experience address 🚀🚀🚀

🚀🚀🚀 GitHub project address (welcome ⭐) 🚀🚀🚀

If you like this little tool, please give it a star ⭐, thank you!

Note: Because the public account does not support external links, you can click the original article link at the end to jump.

About the Iteration

After the Custom Rabbit Year Avatar was launched, many users gave suggestions and feedback; with their help the tool has been continuously improved (e.g., clearer export images, opacity settings). By version 1.4.0 it is stable, and we thank everyone for the support.

Since the original focus was on the Rabbit Year and Chinese New Year themes, the tool style was single and functionality limited. This led to a major version upgrade to Custom Avatar 2.0 .

Update Content

Repository Name

[x] Changed from custom-rabbitImage to custom-avatar

Page

[x] Rebuilt overall page style to a generic style

[x] Compatible with PC and mobile

[x] Mobile avatar wall uses waterfall flow

Canvas Related

[x] User‑uploaded original image is short‑side adapted to avoid distortion

[x] Optimized element control effects, added delete control

[x] Optimized drawing logic, reduced unnecessary calculations

New Features

[x] Added multiple theme options (Mid‑Autumn, National Day, Spring Festival, etc.; more traditional festivals coming soon)

[x] Added sticker effect, selectable and removable

[x] Added quick switch for avatar frames

[x] Added notification feature (e.g., a user customized a National Day avatar 3 minutes ago)

[x] Added share‑poster function

[x] Added avatar wall where users can preview others' customized avatars

Fixed Known Issues

[x] Fixed QQ browser file selection issue

[x] Fixed WeChat browser image‑saving issue

Project Architecture

vue3 | vite | ts | less | Element UI | eslint | stylelint | husky | lint‑staged | commitlint

Required Assets

Avatar frames and stickers are being designed and will be added gradually.

Mid‑Autumn Theme

National Day Theme

Spring Festival Theme

Idea

The basic idea remains unchanged from the Rabbit Year avatar customization described earlier, so it will not be repeated here.

Canvas Interaction Logic Optimization

This is the first version of the logic diagram:

Considering that the avatar tool will not have many layers and the functionality is not overly complex, the new version optimizes as follows:

Removed multi‑layer drawing logic (listen to layer list changes and then draw)

Changed avatar‑frame drawing to an active call, reducing unnecessary call frequency

Sticker drawing is also an active call and can handle multiple stickers

Deleted canvas operation synchronization logic (no need to echo data back to the page or redraw)

After these optimizations the code size decreased noticeably; the only regret is that some ideas from other projects were copied without enough consideration.

Code Implementation

Canvas

Initialize canvas and controls

const init = () => {
    /* Initialize controls */
    initFabricControl()

    /* Initialize canvas */
    Canvas = initCanvas(CanvasId.value, canvasSize, false)

    // Element scaling event
    Canvas.on('object:scaling', canvasMouseScaling)
}

/* Initialize controls */
const initFabricControl = () => {
    fabric.Object.prototype.set(control)
    // Set scaling knob offset
    fabric.Object.prototype.controls.mtr.offsetY = control.mtrOffsetY
    // Hide unnecessary controls
    hiddenControl.map((name: string) => (fabric.Object.prototype.controls[name].visible = false))

    /* Add delete control */
    const delImgElement = document.createElement('img')
    delImgElement.src = new URL('./icons/delete.png', import.meta.url).href

    const size = 52

    const deleteControlHandel = (e, transform:any) => {
        const target = transform.target
        const canvas = target.canvas
        canvas.remove(target).renderAll()
    }

    const renderDeleteIcon = (ctx:any, left:any, top:any, styleOverride:any, fabricObject:any) => {
        ctx.save()
        ctx.translate(left, top)
        ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle))
        ctx.drawImage(delImgElement, -size / 2, -size / 2, size, size)
        ctx.restore()
    }

    fabric.Object.prototype.controls.deleteControl = new fabric.Control({
        x: 0.5,
        y: -0.5,
        cornerSize: size,
        offsetY: -48,
        offsetX: 48,
        cursorStyle: 'pointer',
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        mouseUpHandler: deleteControlHandel,
        render: renderDeleteIcon
    })
}

Watch for changes to the original image (user‑uploaded avatar) and perform short‑side adaptation

/* Change original image */
watch(() => props.bg, async (val) => (await drawBackground(Canvas, val)))

/**
 * @function drawBackground Draw background
 * @param { Object } Canvas canvas instance
 * @param { String } bgUrl user‑uploaded image URL
 */
export const drawBackground = async (Canvas, bgUrl: string) => {
    return new Promise((resolve:any) => {
        if (!bgUrl) return resolve()

        fabric.Image.fromURL(bgUrl, (img:any) => {
            img.set({
                left: Canvas.width / 2,
                top: Canvas.height / 2,
                originX: 'center',
                originY: 'center'
            })

            /* Short‑side adaptation */
            img.width > img.height ? img.scaleToHeight(Canvas.height, true) : img.scaleToWidth(Canvas.width, true)
            Canvas.setBackgroundImage(img, Canvas.renderAll.bind(Canvas))

            resolve()
        }, { crossOrigin: 'Anonymous' })
    })
}

Draw avatar frame and hide delete button control

const frameName = 'frame'

/**
 * @function addFrame Add avatar frame layer
 * @param { String } url frame image URL
 */
const addFrame = async (url = '') => {
    if (!url) return

    const frameLayer:any = await drawImg(`${ url }!frame`)
    frameLayer.set({
        left: Canvas.width / 2,
        top: Canvas.height / 2
    })

    /* Hide delete button */
    frameLayer.setControlVisible('deleteControl', false)

    frameLayer.scaleToWidth(Canvas.width, true)

    frameLayer.name = frameName
    addOrReplaceLayer(Canvas, frameLayer)
}

Set avatar frame opacity

/**
 * @function setFrameOpacity Set avatar frame opacity
 * @param { Number } opacity opacity value
 */
const setFrameOpacity = (opacity = 1) => {
    const frameLayer:any = findCanvasItem(Canvas, frameName)[1] || ''

    if (!frameLayer) return

    frameLayer.set({ opacity })
    Canvas.renderAll()
}

Draw sticker

/**
 * @function addMark Add sticker
 * @param { String } url sticker image URL
 */
const addMark = async (url) => {
    if (!url) return

    const markLayer:any = await drawImg(url)
    markLayer.set({
        left: Canvas.width / 2,
        top: Canvas.height / 2
    })

    markLayer.width > markLayer.height ? markLayer.scaleToHeight(200, true) : markLayer.scaleToWidth(200, true)

    markLayer.name = `mark-${ createUuid() }`
    addOrReplaceLayer(Canvas, markLayer)
}

Save image and export as base64

/**
 * @function save Save result image
 * @return { String } result base64 for preview or download
 */
const save = async (): Promise
=> {
    return Canvas.toDataURL({
        format: 'png',
        left: 0,
        top: 0,
        width: Canvas.width,
        height: Canvas.height
    })
}

Now the code is much clearer, like a ray of light after darkness.

Page Interaction

User uploads an image, generates a local URL, draws the original avatar, and automatically applies the first frame.

const uploadFile = async (e:any) => {
    if (!e.target.files || !e.target.files.length) return ElMessage.warning('Upload failed!')

    const file = e.target.files[0]
    if (!file.type.includes('image')) return ElMessage.warning('Please upload a valid image!')

    const url = getCreatedUrl(file) ?? ''
    /* When the user uploads the avatar for the first time, select the first frame */
    if (!originAvatarUrl.value) {
        originAvatarUrl.value = url
        selectFrame(0)
    } else {
        originAvatarUrl.value = url
    }

    (document.getElementById('uploadImg') as HTMLInputElement).value = ''
}

User clicks a frame or the quick‑switch button to draw the selected frame.

/* Quick switch frame */
const changeFrame = (isNext) => {
    if (!originAvatarUrl.value) return ElMessage.warning('Please upload an avatar first!')

    const frameList = picList[styleIndex.value].frameList
    if (isNext) {
        (selectFrameIndex.value === frameList.length - 1) ? selectFrameIndex.value = 0 : (selectFrameIndex.value as number)++
    } else {
        (selectFrameIndex.value === 0) ? selectFrameIndex.value = frameList.length - 1 : (selectFrameIndex.value as number)--
    }
    selectFrame(selectFrameIndex.value as number)
}

/* Draw avatar frame – call canvas function */
const selectFrame = (index:number) => {
    if (!originAvatarUrl.value) return ElMessage.warning('Please upload an avatar first!')

    opacity.value = 1
    selectFrameIndex.value = index
    frameUrl.value = picList[styleIndex.value].frameList[index]
    DrawRef.value.addFrame(frameUrl.value)
}

Set avatar frame opacity.

const opacity = ref
(1)
const opacityChange = (num:number) => DrawRef.value.setFrameOpacity(num)

Click a sticker to add it to the canvas.

const selectMark = (index:number) => {
    if (!originAvatarUrl.value) return ElMessage.warning('Please upload an avatar first!')

    const markUrl = picList[styleIndex.value].markList[index]
    DrawRef.value.addMark(markUrl)
}

The page interaction logic is relatively simple; follow the steps one by one.

Scrolling Notification Animation

This uses Vue transition animation to simulate scrolling; when the key changes, the enter/leave animation is triggered.

<transition name="notice" mode="out-in">
    <div v-if="avatarList && avatarList.length" class="notice" :key="avatarList[noticeIndex].last_modified">
        <p>
            <span style="color: #409eff;">Guest {{ (avatarList[noticeIndex].last_modified + '').slice(-5) }}</span>
            <span style="padding-left: 2px;">{{ calcOverTime(avatarList[noticeIndex].last_modified) }} ago</span>
            <span style="padding-right: 2px;">made</span>
            <span style="color: #f56c6c;">{{ styleEnums[avatarList[noticeIndex].id] }} avatar</span>
            <img :src="avatarList[noticeIndex].url" alt="">
        </p>
    </div>
</transition>

Poster Function

This uses the html2canvas library; normal CSS properties are sufficient.

<!-- Generate poster -->
<div id="poster" class="poster">
</div>
/* Pay attention to cross‑origin images */
await nextTick(() => {
    /* Generate poster */
    const posterDom = document.getElementById('poster') as HTMLElement
    html2canvas(posterDom, { useCORS: true }).then((canvas) => {
        shareUrl.value = canvas.toDataURL('image/png')
        shareShow.value = true
        loading.value = false
    })
})

Mobile Waterfall Flow Implementation

Both PC and mobile use grid layout; for mobile the row/column span is random, while PC forces a span of 1 to keep squares for exported avatars.

grid-auto-flow: dense; is the key style.

<div class="wall">
    <div class="wall-list">
        <el-image v-for="(url, index) in avatarPageUrlList" :key="url" :src="url"
            :style="{ gridColumn: `span ${ avatarList[index].span }`, gridRow: `span ${ avatarList[index].span }` }" />
    </div>
</div>
.wall {
    .wall-list {
        display: grid;
        gap: 8px;
        grid-template-columns: repeat(8, minmax(0, 1fr));
        grid-auto-flow: dense;
    }

    .wall-more {
        padding-top: 16px;
        text-align: center;
    }
}

/* PC does not use waterfall flow, force row/column span */
@media only screen and (min-width: 769px) {
    .wall {
        .wall-list {
            > div {
                grid-row: span 1 !important;
                grid-column: span 1 !important;
            }
        }
    }
}

At this point the core and detailed points are implemented; for more design and development thoughts, please visit the GitHub repository (the code is open‑source).

About Open Source

The journey has been challenging, and the project still has many shortcomings that cannot be solved overnight. Your suggestions and feedback are welcome, as they make open‑source projects more attractive and allow collective improvement. I hope this tool becomes more refined and gains wider appreciation.

Final Thoughts

I am considering launching a creative‑tool column in the future, but it still needs further deliberation.

If this article helped you, please consider following, liking, and bookmarking .

frontendTypeScriptcanvasVue3fabric.jsavatar customization
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.