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.
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 | commitlintRequired 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 .
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.