Skip to content

Instantly share code, notes, and snippets.

@e1blue
Created March 21, 2018 13:59
Show Gist options
  • Save e1blue/128e64f1de85ba04cfdbe12965760603 to your computer and use it in GitHub Desktop.
Save e1blue/128e64f1de85ba04cfdbe12965760603 to your computer and use it in GitHub Desktop.
Vue'do – A CSS-drawn virtual hair salon using VueJS
<div class="no-props">
<p>Your browser doesn't support CSS Custom Properties. 😭</p>
</div>
<div id="app" :style="{'--skin-shade': lightness, '--hair-color': picked.currentColor}">
<main>
<person :currenthairdo="picked.currentStyle" ref="person"></person>
<skintone :shade="picked.skinShade" @changeshade="updateShade($event)"></skintone>
<hair-styles :selected="picked.currentStyle"></hair-styles>
<hair-color :color="picked.currentColor" @changecolor="updateColor($event)"></hair-color>
<save :saveimg="downloadImg">
</save>
</main>
</div>
<!-- component templates -->
<!-- header -->
<template id="header-temp">
<header>
<hair-icon hairdo="ponytail"></hair-icon>
<h1>{{title}}</h1>
</header>
</template>
<!-- character -->
<template id="person-temp">
<div role="img" aria-label="css-drawn character dynamically updated through choices you make for skin color, hair style, and hair color" class="person__container">
<div aria-hidden="true" class="person">
<div :class="['head', currenthairdo]">
<div class="eyes">
<div class="eye eye--left">
</div>
<div class="eye eye--right"></div>
</div>
<div class="hair">
<div class="hair__bangs"></div>
<div class="hair__add-on"></div>
</div>
<div class="nose"></div>
<div class="mouth"></div>
</div>
<div class="neck"></div>
<div class="shirt"></div>
</div>
</div>
</template>
<!-- skintone range -->
<template id="skin-temp">
<div class="skin">
<label for="skin-picker" class="sr-only">Skin color
</label>
<span id="skin-shade-desc" class="sr-only">Values are from darkest to lightest shade.</span>
<input aria-describedby="skin-shade-desc" class="skin-range" type="range" min="2" max="9" id="skin-picker" step=".5" :value="shade" @input="changeShade">
</div>
</template>
<!-- group of styles -->
<template id="styles-temp">
<div role="radiogroup" aria-labelledby="hair-group" class="styles">
<span id="hair-group" class="sr-only">Hairstyles</span>
<hair-style v-for="hairdo in hairdos" :hairdo="hairdo" :selected="selected" @changestyle="updateStyle($event)" :key="hairdo.name"></hair-style>
</div>
</template>
<!-- individual styles -->
<template id="style-temp">
<label :class="['style-choice', selected === hairdo.name ? 'selected' : '']">
<span class="sr-only">{{hairdo.desc}}</span>
<input class="style-choice__input sr-only" type="radio" name="hair-style" :id="hairdo.name" :checked="hairdo.name == selected" :value="hairdo.name" @change="changeStyle">
<span class="style-choice--focus"></span>
<hair-icon :hairdo="hairdo.name"></hair-icon>
</label>
</template>
<!-- hair style icon -->
<template id="hair-icon-temp">
<div aria-hidden="true" :class="['style-choice', hairdo]">
<div class="style-choice__head">
<div class="style-choice__hair"></div>
</div>
</div>
</template>
<!-- haircolor picker -->
<template id="haircolor-temp">
<label for="haircolor-picker" :class="['haircolor', isText ? '--is-text' : '']">
<span class="sr-only">Haircolor</span>
<input :class="['haircolor__input', !isText ? 'sr-only' : '']" type="color" id="haircolor-picker" :value="color" @input="changeColor" :placeholder="isText ? color : ''">
<span class="haircolor__display"></span>
</label>
</template>
<template id="save-temp">
<button v-if="canSave" class="download" @click="saveimg">
<span class="download__flex">
<span class="download__icon" aria-hidden="true">
<svg id="icon-download" viewBox="0 0 26 28" class="download__icon-svg">
<linearGradient id="static-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fdbcbc"/>
<stop offset="100%" stop-color="#97d6d3"/>
</linearGradient>
<linearGradient id="focus-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#97d6d3"/>
<stop offset="100%" stop-color="#fdbcbc"/>
</linearGradient>
<title>download </title>
<path d="M20 21c0-0.547-0.453-1-1-1s-1 0.453-1 1 0.453 1 1 1 1-0.453 1-1zM24 21c0-0.547-0.453-1-1-1s-1 0.453-1 1 0.453 1 1 1 1-0.453 1-1zM26 17.5v5c0 0.828-0.672 1.5-1.5 1.5h-23c-0.828 0-1.5-0.672-1.5-1.5v-5c0-0.828 0.672-1.5 1.5-1.5h7.266l2.109 2.125c0.578 0.562 1.328 0.875 2.125 0.875s1.547-0.313 2.125-0.875l2.125-2.125h7.25c0.828 0 1.5 0.672 1.5 1.5zM20.922 8.609c0.156 0.375 0.078 0.812-0.219 1.094l-7 7c-0.187 0.203-0.453 0.297-0.703 0.297s-0.516-0.094-0.703-0.297l-7-7c-0.297-0.281-0.375-0.719-0.219-1.094 0.156-0.359 0.516-0.609 0.922-0.609h4v-7c0-0.547 0.453-1 1-1h4c0.547 0 1 0.453 1 1v7h4c0.406 0 0.766 0.25 0.922 0.609z"></path>
</svg>
</span>
<span class="download__text"> download avatar
</span>
</span>
</button>
</template>
(() => {
// don't bother loading the JS if css custom properties are not supported, since the app relies on them
if (CSS.supports && CSS.supports("color", "var(--red)")) {
// event hub/bus
const hub = new Vue();
// header
Vue.component("appHead", {
template: "#header-temp",
props: ["title"]
});
// hair style options
Vue.component("hairStyles", {
template: "#styles-temp",
props: ["updatestyles", "selected"],
data() {
return {
hairdos: [
{
name: "ponytail",
desc: "low ponytail"
},
{
name: "short-straight",
desc: "bob"
},
{
name: "long-straight",
desc: "long and straight"
},
{
name: "bun",
desc: "top knot"
},
{
name: "waves",
desc: "medium-long and wavy"
}
// disable fro until I fix the mini icon on mobile
// {
// name: "fro",
// desc: "wavey afro"
// }
]
};
}
});
// person / character
Vue.component("person", {
template: "#person-temp",
props: ["currenthairdo"]
});
// skin color range
Vue.component("skintone", {
template: "#skin-temp",
props: ["shade"],
methods: {
changeShade(e) {
hub.$emit("changeshade", e.target.value);
}
}
});
// individual hair style option
Vue.component("hairStyle", {
template: "#style-temp",
props: ["hairdo", "selected"],
methods: {
changeStyle(e) {
hub.$emit("changestyle", e.target.value);
}
}
});
// hair style icon
Vue.component("hair-icon", {
template: "#hair-icon-temp",
props: ["hairdo"]
});
// hair color picker
Vue.component("hairColor", {
template: "#haircolor-temp",
props: ["color"],
data() {
return {
isText: false
};
},
methods: {
changeColor(e) {
// if color input is displayed properly, do your thang
if (!this.isText) {
hub.$emit("changecolor", e.target.value);
} else {
// otherwise, a text field is displayed and we need to validate the value before attempting to update the hair color
const color = this.isValidColor(e.target.value);
if (color) {
hub.$emit("changecolor", color);
}
}
},
isTextInput() {
// tests if browser displays color input as a plain text field so styles can be adjusted accordingly
const el = document.createElement("input");
el.setAttribute("type", "color");
document.body.appendChild(el);
const computed = window.getComputedStyle(el);
const appearance = computed.getPropertyValue("-webkit-appearance");
const type = el.type;
document.body.removeChild(el);
return appearance === "textfield" || type === "text";
},
isValidColor(val) {
const strNoSpaces = val.replace(/\s/g, "");
// quick and dirty; doesn't clamp rgb values at 255 or hsl % values at 100
const hexRgbHsl = /(?:^rgb\([0-9]{1,3},[0-9]{1,3},[0-9]{1,3}\)$)|(?:^#(?:[a-f0-9]{6}|[a-f0-9]{3})$)|(?:^hsl\([0-9]{1,3},[0-9]{1,3}%,[0-9]{1,3}%\)$)/gi;
// test against hex/rgb/hsl first since they're more commonly used than CSS color keywords
if (strNoSpaces.match(hexRgbHsl)) {
return strNoSpaces;
} else {
// test against color keywords
const colors = `aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,pink,plum,powderblue,purple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen`;
const reg = new RegExp(`\\b${strNoSpaces}\\b`);
const matches = colors.match(reg);
return matches ? matches[0] : false;
}
}
},
created() {
this.isText = this.isTextInput();
}
});
// download btn
Vue.component("save", {
template: "#save-temp",
props: ["saveimg"],
data() {
return {
canSave: null
};
},
methods: {
canSaveTest() {
// make sure browser can convert the DOM node to an image; otherwise don't show save btn
domtoimage.toBlob(document.body).then(
() => {
this.canSave = true;
},
() => {
this.canSave = false;
}
);
}
},
created() {
this.canSaveTest();
}
});
const app = new Vue({
el: "#app",
data() {
return {
picked: {},
title: `Vue 'Do`,
storage: {}
};
},
methods: {
updateStorage() {},
updateStyle(style) {
this.picked.currentStyle = style;
},
updateShade(newShade) {
this.picked.skinShade = newShade;
},
updateColor(color) {
this.picked.currentColor = color;
},
downloadImg() {
const el = this.$refs.person.$el;
const opts = {
style: {
maxWidth: "none",
margin: "0 "
}
};
domtoimage.toBlob(el, opts).then(blob => {
window.saveAs(blob, "vue-do__my-character.png");
});
}
},
computed: {
lightness() {
return `${this.picked.skinShade * 10}%`;
}
},
watch: {
picked: {
handler() {
if (this.storage.canStore) {
this.updateStorage();
}
},
deep: true
}
},
beforeCreate() {},
created() {
// default options
let opts = {
currentStyle: "ponytail",
currentColor: "#293a97",
skinShade: 7
};
// using this method vs. typeof localStorage so it will also work in cases where localStorage is supported, but disabled
try {
window.localStorage.setItem("owlsayswoot", "wooot?");
window.localStorage.removeItem("owlsayswoot");
this.storage.canStore = true;
this.storage.dataKey = "vue_doJS-picked";
} catch (e) {
}
if (this.storage.canStore) {
// set the options to localStorage if someone has already visited and changed styles
const stored = window.localStorage.getItem(this.storage.dataKey);
if (stored) {
opts = JSON.parse(stored);
}
// create method to set localStorage when any of the data changes
this.updateStorage = () => {
window.localStorage.setItem(
this.storage.dataKey,
JSON.stringify(this.picked)
);
};
}
// set our hair style, color, and skin color to either default or localStorage options
this.picked = opts;
},
mounted() {
hub.$on("changeshade", this.updateShade);
hub.$on("changestyle", this.updateStyle);
hub.$on("changecolor", this.updateColor);
}
});
}
})();
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.16/vue.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dom-to-image/2.6.0/dom-to-image.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.3/FileSaver.min.js"></script>
// very unorganized; definitely on the to-do list to fix
$mirror-bg: #97d6d3;
// solves issue of safari rendering transparent as gray
$transparent: rgba(white, 0);
$mono-stack: 'Courier New','Andale Mono','Courier Next',
courier,
monospace;
// to build skintone scale
@function buildColorScale($n-colors, $light-start, $step, $hue: 10, $sat: 40) {
$stops: $n-colors * 2;
$lightness: $light-start;
$color-chunk: 100 / $n-colors;
$stop-pos: 0;
$grad-stops: "";
@for $stop from 1 through $stops {
@if ($stop % 2 != 0) {
$lightness: ($lightness + $step) * 1%;
}
@if ($stop % 2 == 0) {
$stop-pos: $stop-pos + $color-chunk;
}
$stop-color: hsl($hue, $sat * 1%, $lightness);
@if ($stop == 1) {
$grad-stops: #{$stop-color} $stop-pos * 1%;
} @else {
$grad-stops: #{$grad-stops}, #{$stop-color} $stop-pos * 1%;
}
}
@return linear-gradient(to right, #{$grad-stops});
}
@mixin nose-border($width, $color) {
border-style: solid;
border-width: $width;
border-color: $color;
}
@mixin full() {
width: 100%;
height: 100%;
}
@mixin centerX() {
left: 50%;
transform: translateX(-50%);
}
@mixin centerY() {
top: 50%;
transform: translateY(-50%);
}
@mixin centerXY() {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@mixin polka-dots($dot-d, $bg, $dot-col) {
background-color: $bg;
background-image: radial-gradient($dot-col 15%, $transparent 16%),
radial-gradient($dot-col 15%, $transparent 16%);
background-size: $dot-d $dot-d;
background-position: 0 2px, ($dot-d / 2) ($dot-d / 2 + 2px);
}
@mixin pseudo-base() {
content: "";
position: absolute;
}
// skin range mixins
@mixin slider-track() {
&::-webkit-slider-runnable-track {
@content;
}
&::-moz-range-track {
@content;
}
&::-ms-track {
@content;
}
}
@mixin slider-thumb() {
&::-webkit-slider-thumb {
@content;
}
&::-moz-range-thumb {
@content;
}
&::-ms-thumb {
@content;
}
}
:root {
--bg: #fdbcbc;
font-size: calc(16px + 0.5vw);
}
*,
*:before,
*:after {
box-sizing: inherit;
}
html {
box-sizing: border-box;
height: 100%;
}
body {
margin: 0;
min-height: 100%;
font-family: sans-serif;
background-color: var(--bg);
}
label,
button {
cursor: pointer;
}
#app {
--header-h: 2rem;
--rad: 10;
--hair-hue: 190;
--skin-hue: 15;
--skin-shade: 30%;
--skin-color: hsl(var(--skin-hue), 40%, var(--skin-shade));
--hair-color: hsl(220, 70%, 60%);
height: 100%;
width: 100%;
}
main {
padding: 0 3vmax;
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.no-props {
display: none;
margin: auto;
padding: 1rem;
max-width: 30rem;
background-color: $mirror-bg;
text-align: center;
}
.sr-only {
border: 0 !important;
clip: rect(1px, 1px, 1px, 1px) !important;
-webkit-clip-path: inset(50%) !important;
clip-path: inset(50%) !important;
height: 1px !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
width: 1px !important;
white-space: nowrap !important;
}
header {
display: flex;
position: relative;
width: 100%;
height: var(--header-h);
margin-bottom: 2rem;
align-items: center;
padding: 1.5em 1em;
background-color: rgba(#fff, 0.8);
&::before {
@include pseudo-base;
bottom: -0.5rem;
left: 0;
height: 0.5rem;
width: 100%;
background-image: linear-gradient(
to right,
lighten($mirror-bg, 13%) 40%,
var(--bg)
);
transform-origin: left;
transform: scale(0);
animation: grad-scale 2s forwards;
}
& .style-choice {
--choice-sz: 12vmin;
}
}
@keyframes grad-scale {
to {
transform: scale(1);
}
}
h1 {
margin: 0;
font-size: 1.5rem;
}
.style-choice {
--choice-sz: 6.25rem;
--choice-sz: 17vmin;
position: relative;
display: block;
width: var(--choice-sz);
height: var(--choice-sz);
&::before {
@include pseudo-base;
@include full;
top: 0;
left: 0;
border: 0.4rem dotted #fff;
opacity: 0;
transition: opacity 0.2s;
}
&.selected::before {
opacity: 1;
}
}
.style-choice__head {
@include centerXY;
// don't change option color when hair color changes; otherwise if hair color is too light, it won't be visible
--hair-color: #222;
position: absolute;
height: 50%;
width: 50%;
border-radius: 50%;
box-shadow: inset 0.25rem 0.3rem var(--hair-color),
inset -0.25rem 0.3rem var(--hair-color);
}
.style-choice__hair {
@include full;
position: absolute;
border-radius: inherit;
&::before,
&::after {
@include pseudo-base;
}
// bangs
&::before {
--bang-w: calc(var(--choice-sz) * 0.3);
@include full;
border-radius: inherit;
background-image: radial-gradient(
circle at top left,
var(--hair-color) var(--bang-w),
$transparent var(--bang-w)
),
radial-gradient(
circle at top right,
var(--hair-color) var(--bang-w),
$transparent var(--bang-w)
);
}
}
.bun .style-choice__hair {
&::after {
@include centerX;
top: -40%;
border-radius: 50%;
width: 60%;
height: 50%;
background-color: var(--hair-color);
}
}
.ponytail .style-choice__hair {
&::after {
width: 50%;
height: 80%;
left: -20%;
top: 30%;
border-radius: 50%;
background-image: radial-gradient(
circle at right,
$transparent 50%,
var(--hair-color) 50%
);
}
}
.short-straight .style-choice__hair,
.long-straight .style-choice__hair {
// bangs
&::before {
background-image: radial-gradient(
ellipse at -5px 10px,
$transparent,
$transparent 20%,
var(--bg) 20%,
var(--bg) 30%,
$transparent 30%
),
linear-gradient(var(--hair-color) 45%, $transparent 45%);
background-size: 28% 50%, 100% 100%;
background-position: 40% 45%, 0 0;
background-repeat: no-repeat;
}
}
.short-straight .style-choice__hair {
&::after {
width: 120%;
height: 120%;
left: -10%;
top: -10%;
border-radius: 50%;
z-index: -1;
background-image: radial-gradient(
circle at right,
$transparent 75%,
var(--hair-color) 75%
),
radial-gradient(circle at left, $transparent 75%, var(--hair-color) 75%),
linear-gradient(var(--hair-color) 25%, $transparent 25%);
}
}
.long-straight .style-choice__hair {
&::after {
width: 120%;
height: 140%;
left: -10%;
top: -10%;
border-radius: 50% 50% 0 0;
z-index: -1;
background-image: linear-gradient(
to right,
var(--hair-color) 20%,
$transparent 20%,
$transparent 80%,
var(--hair-color) 80%
),
linear-gradient(var(--hair-color) 25%, $transparent 25%);
}
}
.waves .style-choice__hair {
&::after {
$wave: radial-gradient(circle, var(--hair-color) 50%, $transparent 50%);
$big-wave: 1.5rem;
$small-wave: 1.25rem;
width: 140%;
height: 110%;
left: -20%;
bottom: -40%;
z-index: -1;
background-image: $wave, $wave, $wave, $wave, $wave, $wave,
linear-gradient(
to right,
var(--hair-color) 20%,
$transparent 20%,
$transparent 80%,
var(--hair-color) 80%
);
background-size: $big-wave $big-wave, $small-wave $small-wave,
$big-wave $big-wave, $big-wave $big-wave, $small-wave $small-wave,
$big-wave $big-wave, 80% 45%;
background-repeat: no-repeat;
background-position: 0 0, 2% 35%, -10% 60%, 100% 0, 98% 35%, 110% 60%,
center center;
}
}
.fro .style-choice__hair {
&::after {
width: .85rem;
height: .85em;
left: -.25rem;
background-color: var(--hair-color);
border-radius: 50%;
box-shadow: 1rem -.5rem 0 -.15rem var(--hair-color), .5rem -.25rem var(--hair-color), -.25rem .5rem var(--hair-color), -.15rem 1rem var(--hair-color), .15rem 1.15rem 0 -.15rem var(--hair-color), .15rem 1.35rem 0 -.1rem var(--hair-color), 1rem -.25rem var(--hair-color), 1.6rem 0 var(--hair-color), 2rem .25rem var(--hair-color), 2.15rem .75rem var(--hair-color), 2rem 1rem var(--hair-color), 1.85rem 1.15rem 0 -.15rem var(--hair-color), 1.85rem 1.35rem 0 -.15rem var(--hair-color);
}
}
.styles {
display: flex;
justify-content: space-evenly;
margin: auto;
max-width: 30rem;
}
.person {
position: relative;
max-width: 10rem;
z-index: 5;
}
.neck {
$dot-d: 15px;
position: absolute;
left: 50%;
width: 5rem;
height: 3rem;
transform: translate(-50%, -25%);
border-radius: 50%;
background-color: var(--skin-color);
box-shadow: inset 0 -3rem rgba(0, 0, 0, 0.05), inset 0.25rem 0 grey,
inset -0.25rem 0 grey, inset 0 0 grey;
z-index: -1;
overflow: hidden;
border-bottom: 0.3rem solid grey;
}
.shirt {
@include polka-dots(15px, hsl(50, 0%, 20%), rgba(255, 255, 255, 0.5));
left: 50%;
position: absolute;
width: 100%;
height: 60%;
bottom: -75%;
border-radius: 35% 35% 0 0;
z-index: -2;
transform: translate(-50%, -25%);
&::before,
&::after {
@include pseudo-base;
@include polka-dots(15px, hsl(50, 0%, 20%), rgba(255, 255, 255, 0.5));
width: 1.5rem;
height: 5rem;
border-radius: 35% 35% 5% 5%;
left: -0.75rem;
bottom: 0;
}
&::after {
left: auto;
right: -0.75rem;
}
}
.head {
position: relative;
width: calc(var(--rad) * 1rem);
height: calc(var(--rad) * 1rem);
border-radius: 50%;
box-shadow: inset var(--sideburn-x) var(--haircolor),
var(--right-sideburn-x) var(--sideburn-y) var(--haircolor);
&::before,
&::after {
@include pseudo-base;
width: inherit;
height: inherit;
border-radius: inherit;
}
&::before {
$blush: radial-gradient(hsla(340, 100%, 60%, 0.2) 0%, $transparent 75%);
// skin on face
background-color: var(--skin-color);
// blush
background-image: $blush, $blush;
background-size: 2rem 2rem;
background-position: 25% 75%, 75% 75%;
background-repeat: no-repeat;
}
&::after {
width: 120%;
height: 110%;
left: -10%;
top: -5%;
}
}
.head.bun::before {
box-shadow: inset 0.5rem 1.5rem 0 var(--hair-color),
inset -0.25rem 1.5rem 0 var(--hair-color);
}
.hair__add-on {
position: absolute;
z-index: -2;
&::before,
&::after {
@include pseudo-base;
}
}
.short-straight .hair__add-on {
--width-inc: 14%;
--height-inc: 10%;
overflow: hidden;
border-radius: 100%;
}
.short-straight .hair__add-on,
.long-straight .hair__add-on {
position: absolute;
width: calc(var(--width-inc) + 100%);
height: calc(var(--height-inc) + 100%);
left: calc(var(--width-inc) / 2 * -1);
top: -15%;
background-color: var(--hair-color);
z-index: -3;
}
.long-straight .hair__add-on {
--width-inc: 30%;
--height-inc: 50%;
border-radius: 50% 50% 10% 10%;
// choppiness at the bottom
&::before {
width: 100%;
height: 4rem;
background-image: linear-gradient(
-45deg,
$mirror-bg 60%,
var(--hair-color) 60%
);
background-repeat: repeat-x;
background-size: 0.4rem 3rem;
background-position: 0 100%;
bottom: -1rem;
z-index: 10;
}
}
// face-framing hair__bangs
.head.short-straight::after,
.head.long-straight::after {
box-shadow: inset 1.25rem 1rem var(--hair-color),
inset 2.5rem 1.2rem var(--hair-color), inset -1.25rem 1rem var(--hair-color),
inset -2.5rem 1.2rem var(--hair-color);
}
.short-straight .hair__bangs,
.long-straight .hair__bangs {
&::before,
&::after {
width: 100%;
height: 4rem;
transform: none;
border-radius: 100% 100% 0 0;
}
&::after {
width: 3rem;
height: 3rem;
background-color: $transparent;
top: 2.1rem;
right: auto;
left: 1.75rem;
border-radius: 0 50% 50% 0;
// border-radius: 50%;
box-shadow: inset -.15rem -.4rem var(--skin-color);
}
}
.bun .hair__bangs {
top: 0rem;
overflow: hidden;
&::before,
&::after {
width: calc(var(--rad) * 0.7rem);
height: calc(var(--rad) * 0.6rem);
border-radius: 50%;
background-color: var(--hair-color);
}
// left side
&::before {
left: 0;
z-index: 5;
box-shadow: 0.25rem 0.25rem 0 var(--hair-color);
transform: rotate(32deg);
transform-origin: top right;
// highlights
background-image: radial-gradient(
ellipse at 2.5rem 2.5rem,
$transparent 65%,
rgba(255, 255, 255, 0.1) 65%
),
radial-gradient(
ellipse at 2.5rem 2.5rem,
$transparent 65%,
rgba(255, 255, 255, 0.1) 65%
),
radial-gradient(
ellipse at 1.25rem 1.25rem,
$transparent 65%,
rgba(255, 255, 255, 0.15) 65%
);
}
// right side
&::after {
right: 0;
transform: translate(25%, -25%);
// lowlights
box-shadow: inset 0.25rem -0.25rem rgba(0, 0, 0, 0.2),
inset 0.35rem -0.5rem rgba(0, 0, 0, 0.1);
border-radius: 20% 50% 30% 70%;
}
}
.hair {
@include full;
position: absolute;
top: 0;
left: 0;
}
.waves .hair__add-on {
@include full;
position: absolute;
top: 2rem;
&::before,
&::after {
left: -0.5rem;
z-index: -1;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: var(--hair-color);
box-shadow: -1.5rem 2rem;
box-shadow: 5rem -2.55rem var(--hair-color),
4rem 0.75rem 0 4rem var(--hair-color), 3rem -2.5rem var(--hair-color),
2rem -2rem var(--hair-color), 0.5rem -1rem var(--hair-color),
-1rem 2rem var(--hair-color), -1rem 4rem var(--hair-color),
-1.5rem 6rem var(--hair-color), 0 6rem var(--hair-color),
1rem 6rem var(--hair-color);
}
&::after {
transform: scaleX(-1);
right: -0.5rem;
left: auto;
}
}
.hair__bangs::before,
.hair__bangs::after {
@include pseudo-base;
}
.ponytail .hair__bangs {
box-shadow: 0rem -1rem 0 0.5rem var(--hair-color);
}
.ponytail .hair__add-on {
@include full;
position: absolute;
top: 0;
&::before,
&::after {
top: 3rem;
z-index: -1;
width: 5rem;
height: 10rem;
}
&::before {
left: -1.75rem;
border-radius: 100% 50% 100% 100% / 100%;
background-color: var(--hair-color);
box-shadow: inset 0.5rem -1rem rgba(255, 255, 255, 0.1),
inset 1rem -1.5rem rgba(255, 255, 255, 0.1),
inset 1.25rem -2rem $transparent, inset 1.5rem -3rem rgba(0, 0, 0, 0.1);
transform-origin: top right;
transform: rotate(5deg);
}
&::after {
background-color: $mirror-bg;
border-radius: 100%;
left: -0.15rem;
height: 6.5rem;
}
}
.waves .hair__bangs,
.ponytail .hair__bangs {
top: 1%;
&::before,
&::after {
height: 6rem;
}
&::before {
border-radius: 50% 30% 50% 0;
}
&::after {
border-radius: 30% 100% 0 100%;
right: 0.25rem;
}
}
.hair__bangs {
@include full;
position: relative;
border-radius: 50%;
&::before,
&::after {
width: var(--sz);
height: var(--sz);
background-color: var(--hair-color);
border-radius: var(--br);
transform: skewX(var(--deg)) translateY(-1rem);
}
&::before {
--br: 0 30% 50% 0;
--deg: -15deg;
--sz: 5rem;
}
&::after {
--br: 30% 0 0 50%;
--deg: 15deg;
--sz: 4rem;
right: -0.25rem;
}
}
.nose {
$color: #9a6966;
@include nose-border(0 0.3rem 1rem 0.3rem, $transparent $transparent $color);
position: absolute;
left: 50%;
top: 60%;
transform: translateX(-50%);
mix-blend-mode: hard-light;
z-index: 10;
&::after {
@include pseudo-base;
@include nose-border(0.3rem 0.3rem 0 0.3rem, $color $transparent $transparent);
left: -0.3rem;
bottom: -1.3rem;
}
}
.bun .hair__add-on {
width: calc(var(--rad) * 0.6rem);
height: calc(var(--rad) * 0.45rem);
background-color: red;
background-color: var(--hair-color);
position: absolute;
z-index: 15;
border-radius: 50%;
left: 0;
top: 0;
transform: translate(30%, -80%);
box-shadow: inset 0.75rem -1.5rem 0em -1.25rem rgba(0, 0, 0, 0.1),
inset -0.25rem -1.75rem 0em -1.25rem rgba(0, 0, 0, 0.15);
}
.skin {
margin: 0 auto 1.5rem;
}
.skin-range {
$n-colors: 15;
$border-w: 0.15em;
$d: 1.75;
$thumb-sz: 1.65rem;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
position: relative;
margin: auto;
display: block;
// only make it wide enough for the amount of colors needed
width: $n-colors * $thumb-sz;
max-width: 100%;
height: $thumb-sz;
background-color: $transparent;
// 15: number of skintones; 15(%): darkest value for lightness in skintones; 5(%): amount to increase shade with each skintone; 10: hue value; 40(%): saturation value
background-image: buildColorScale(15, 15, 5, 10, 40);
cursor: pointer;
@include slider-thumb {
position: relative;
transform: translate($border-w * -0.5, $border-w * -0.5);
width: $d * 1em;
height: $d * 1em;
cursor: pointer;
background-color: hsl(var(--skin-hue), 40%, var(--skin-shade));
border: $border-w solid #fff;
border-radius: 50%;
-webkit-appearance: none;
}
@include slider-track {
height: $thumb-sz;
background-image: buildColorScale(15, 15, 5, 10, 40);
}
&:focus {
outline: 1px solid #fff;
border: 0;
}
}
.person__container {
position: relative;
width: 100%;
max-width: 18rem;
min-height: 23rem;
margin: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0.5rem;
border: 0.75rem solid wheat;
background-color: $mirror-bg;
background-image: linear-gradient(
-130deg,
lighten($mirror-bg, 5%) 30%,
$mirror-bg 30%
);
&::after {
@include pseudo-base;
$reflection-sz: 1rem;
$reflection-col: lighten($mirror-bg, 15%);
width: 3rem;
height: 0.2rem;
right: 5%;
top: 10%;
z-index: 10;
background-color: $reflection-col;
transform: rotate(45deg);
box-shadow: $reflection-sz $reflection-sz $reflection-col,
($reflection-sz * -1) $reflection-sz $reflection-col;
}
}
.mouth {
$mouth-w: 1.5rem;
$lip-color: hsl(0, 65%, 40%);
position: absolute;
left: 42%;
bottom: 1rem;
width: $mouth-w;
height: $mouth-w;
background-color: $lip-color;
background-image: linear-gradient(
45deg,
$lip-color 50%,
darken($lip-color, 4%) 50%
);
transform: rotate(135deg);
border-radius: 0 40% 0 60%;
z-index: 1;
mix-blend-mode: color-burn;
// cupid's bow
&::after {
@include pseudo-base;
$sz: 30%;
width: $sz;
height: $sz;
left: 0%;
bottom: 0;
border-radius: 20%;
background-color: #fff;
}
}
.eyes {
@include full;
position: absolute;
left: 0;
top: 0;
//eyebrows
&::before,
&::after {
@include pseudo-base;
width: 2.2rem;
height: 1rem;
border-top: .25rem solid var(--hair-color);
top: 42%;
mix-blend-mode: multiply;
}
// left brow
&::before {
// border-radius: 50% 10% 0% 35%;
border-radius: 40% 10% 0% 50%;
left: 1.8rem;
}
// right brow
&::after {
border-radius: 10% 40% 50% 0%;
right: 1.8rem;
}
}
.eye {
--border: 4px solid black; // upper lashline
$sz: 1rem;
position: absolute;
top: 50%;
height: $sz;
width: $sz;
transform: rotate(var(--deg));
z-index: 2;
// pupil
&::before {
@include pseudo-base;
@include full;
z-index: 10;
background-color: white;
background-image: radial-gradient(circle, black 40%, white 40%);
background-repeat: no-repeat;
background-position: 0% 0%;
border-radius: var(--br);
}
// winged liner
&::after {
@include pseudo-base;
top: -0.15rem;
width: 0.75rem;
height: 0.55rem;
background-color: transparent;
border-radius: 50%;
box-shadow: inset -0.2rem -0.15rem black;
}
}
.eye--left {
--deg: -25deg;
--br: 50% 50% 0% 40%;
left: 25%;
// top liner
&::before {
border-top: var(--border);
border-right: var(--border);
}
// winged liner
&::after {
left: -0.45rem;
transform: rotate(45deg);
}
}
.eye--right {
--deg: 25deg;
--br: 50% 50% 40% 0%;
right: 25%;
// top liner
&::before {
border-top: var(--border);
border-left: var(--border);
}
// winged liner
&::after {
right: -0.5rem;
transform: scaleX(-1) rotate(45deg);
}
}
.fro .hair__bangs {
display: none;
}
.fro .hair__add-on {
position: absolute;
@include full;
top: 0;
z-index: 1;
border-radius: 50%;
transform: scale(0.9);
&::before,
&::after {
@include pseudo-base;
width: 3rem;
height: 3rem;
background-color: var(--hair-color);
border-radius: 50%;
}
// face-framing waves
&::before {
top: 50%;
left: -1rem;
transform: translateY(-50%);
box-shadow: 0.25rem -2rem var(--hair-color),
1rem -4rem 0 0.25rem var(--hair-color),
3rem -5rem 0 0.5rem var(--hair-color),
6rem -4.5rem 0 0.15rem var(--hair-color),
8.5rem -3rem 0 0.4rem var(--hair-color),
9.5rem -1rem 0 -0.25rem var(--hair-color),
9.5rem 0 0 -0.5rem var(--hair-color);
}
// outer waves
&::after {
top: 1rem;
left: -1rem;
background-color: var(--hair-color);
box-shadow: 0.25rem -2rem var(--hair-color),
1rem -4rem 0 0.25rem var(--hair-color),
3rem -4.75rem 0 0.5rem var(--hair-color),
6rem -4.5rem 0 0.5rem var(--hair-color),
8.5rem -3rem 0 0.5rem var(--hair-color),
10rem -1.5rem 0 -0.25rem var(--hair-color),
10.5rem 0 0 -0.25rem var(--hair-color), 0.5rem 2rem var(--hair-color),
10rem 2rem 0 -0.25rem var(--hair-color),
9.5rem 3rem 0 -0.6rem var(--hair-color), 2.75rem -3.75rem var(--hair-color),
1rem 3.25rem 0 -0.5rem var(--hair-color);
transform: scale(1.2) translateX(-50%);
}
}
.char,
.person__container {
margin-bottom: 2rem;
}
.haircolor {
display: block;
max-width: 10rem;
height: 2.5rem;
position: relative;
margin: 1.5rem auto;
}
.haircolor__display {
@include full;
display: block;
background-color: var(--hair-color);
border: 6px solid #fff;
transition: transform 0.2s ease-in-out, opacity .2s ease-in-out;
will-change: transform;
.haircolor:hover & {
opacity: .85;
}
.haircolor:not(.--is-text) .haircolor__input:focus + & {
transform: scale(1.1);
}
}
.--is-text {
max-width: 20rem;
display: flex;
.haircolor__display {
width: 48%;
}
.haircolor__input {
display: block;
width: 48%;
height: 100%;
margin-left: auto;
order: 2;
padding-left: 0.5em;
border: 0;
font-family: $mono-stack;
border-bottom: 6px solid var(--hair-color);
&:focus {
background-color: lighten($mirror-bg, 15%);
outline: none;
}
}
}
.style-choice--focus {
$size-inc: 6%;
$size: 100% + $size-inc;
$offset: $size-inc * -0.5;
position: absolute;
top: $offset;
left: $offset;
width: $size;
height: $size;
background-color: $mirror-bg;
background-color: transparentize(#fff, .65);
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: -1;
.style-choice__input:focus + &,
.style-choice:hover & {
opacity: 1;
}
}
.download {
--bg: #fff;
--f-col: #222;
margin: auto;
display: block;
width: 100%;
height: 2.5rem;
max-width: 15rem;
border: none;
padding: 0;
font-size: 18px;
font-family: $mono-stack;
transition: all 0.2s ease-in-out;
background-color: var(--bg);
color: var(--text-col);
&:focus,
&:hover {
--bg: #222;
--text-col: #fff;
outline: 0;
& .download__icon-svg {
fill: url("#focus-grad");
}
}
&:active {
--bg: hsl(180, 80%, 90%);
--text-col: #222;
transform: scale(0.98) translateY(2px);
}
}
.download__flex {
display: flex;
align-items: center;
justify-content: space-evenly;
}
.download__icon-svg {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
fill: url("#static-grad");
stroke: currentColor;
stroke-width: 0;
vertical-align: middle;
}
.download__text {
text-align: center;
}
@media screen and (min-width: 62em) {
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
margin-top: auto;
// max-width: 1300px;
margin-bottom: auto;
display: grid;
justify-items: center;
grid-template-columns: 3rem 1fr 3rem auto 1fr 1rem;
grid-template-rows: 1.5rem 2fr 1fr auto 1.5rem;
}
.person__container {
grid-column: 2 / span 1;
justfy-self: center;
grid-row: 2 / span 2;
margin-left: 0;
margin-right: 0;
}
.skin {
grid-column: 1 / span 3;
grid-row: 4 / span 1;
align-self: center;
}
.style-choice {
--choice-sz: 5rem;
}
.styles {
margin: 0;
max-width: 25rem;
grid-column: 5 / span 1;
grid-row: 2 / span 1;
align-self: center;
flex-wrap: wrap;
}
.haircolor {
grid-column: 5 / span 1;
grid-row: 3 / span 1;
align-self: start;
margin: 0;
width: 75%;
max-width: 18rem;
}
.download {
grid-row: 3 / span 1;
grid-column: 4 / span 2;
margin: 0;
align-self: end;
max-width: 12rem;
}
}
@supports not (color: var(--col)) {
body {
display: flex;
height: 100vh;
}
#app {
display: none;
}
.no-props {
display: block;
}
}

Vue'do – A CSS-drawn virtual hair salon using VueJS

My first time using any front-end framework.

  • Uses LocalStorage to save choices when available

  • Download option is available for browsers that are able to use FilesaverJS and dom-to-image

The app will not be loaded in browsers that don't support CSS Custom Properties since it is 100% reliant on them.

  • In browsers with limited or no color input support, a text field is shown and values are restricted to hsl, rgb, hex, and CSS color keywords

  • Sometimes a weird flash of unstyled content is happening in Chrome when switching styles

A Pen by e1blue on CodePen.

License.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment