DEV Community

Cover image for Kitty Cafe, a whole bakery drawn in CSS šŸ±šŸ°
Jessica Doering
Jessica Doering

Posted on

Kitty Cafe, a whole bakery drawn in CSS šŸ±šŸ°

Frontend Challenge CSS Art Submission šŸ²šŸ„§

This is a submission for Frontend Challenge - Comfort Food Edition, CSS Art.

Inspiration

Comfort food, for me, is not a big plate of anything. It is the small stuff.
The pastry you eat standing up at the counter because you could not wait to sit
down. So I wanted to draw a bakery.

Then I thought: what if every single thing in the case was a cat.

That turned out to be the right call, because it gave me nine little characters
instead of nine pastries, and characters are way more fun to draw. There is a
macaron with ears, a matcha cupcake, a taiyaki, a paw print cookie, a chocolate
donut with far too many sprinkles, a cake slice, a cake pop, and a caramel
pudding cup. Up on the shelf there is a whole kitty cake you have to order 48
hours in advance.

I also did not want it to be a single object floating on a background. I wanted
a room you could stand in. So there is a chalkboard of drinks, jars on a shelf,
a pendant lamp, a wooden counter, and a shop cat asleep next to somebody's
coffee that is definitely going cold.

Demo

No images. No SVG. No canvas. No JavaScript. Every whisker is a div with a
border-radius.

Three things worth poking at:

  • Hover, tap or tab to any treat. It lifts out of the row with a price tag.
  • Click the sleeping cat. She opens her eyes, perks her ears, and purrs for a few seconds before dozing off again.
  • There is a ball of yarn on the floor. Click it to pick it up, click her to give it to her, and see what happens.

Journey

One coordinate system, zero breakpoints

The first real decision was how to make the art scale. CSS art has hundreds of
coordinates in it, and maintaining breakpoint variants of all of them sounded
miserable.

So the whole scene is a container:

.scene {
  container-type: inline-size;
  aspect-ratio: 16 / 10;
}
Enter fullscreen mode Exit fullscreen mode

Everything inside is measured in cqw (1% of the scene's width) or in
percentages. Because the height comes from the width, both axes scale together.
The picture looks identical at 320px and at 1920px, just smaller, and there is
not one media query anywhere in the art.

One gotcha I hit: cqw inside .scene's own rules resolves against some
outer container, not itself. So .scene uses pixels for its own properties and
cqw only ever shows up on descendants.

The scene also sizes itself to your window. Since height follows width at a
fixed ratio, capping the width at (100dvh - 7rem) * 1.6 is the same thing as
capping the height at the viewport:

width: min(100%, max(46rem, calc((100dvh - 7rem) * 1.6)));
Enter fullscreen mode Exit fullscreen mode

max-height does not work here, by the way. It fights aspect-ratio, because
the width has already resolved by the time it applies.

Eight cats pretending to be pastry

Every treat shares one set of parts: an ear, an eye, a mouth, blush, whiskers.
Each treat just re-tunes them with custom properties instead of redefining the
shapes:

.t-macaron {
  --ear-c: var(--pink);
  --ear-dip: 0.9cqw;
  --face-y: 36%;
  --eye-s: 0.88cqw;
}
Enter fullscreen mode Exit fullscreen mode

--ear-dip came out of an actual problem. Ears sit on top of whatever box they
live in, which is fine on a flat head. On a circle or a dome they end up over
the part of the box the shape has already curved away from, so they float, and
the cat looks like it is wearing a bad costume. Round heads just need the ears
tucked deeper.

--eye-c exists for a similar reason. The shared eye used dark ink, and on the
chocolate donut the whole face simply vanished. It read as a plain donut with
sprinkles. Cream eyes fixed it, and then I had to recolour the cream sprinkles
too, because white dots next to white eyes read as extra eyes.

Drips that cannot spill

Icing drips gave me trouble until I found a pattern that makes overflow
impossible by construction. Instead of positioning a drip as its own box, make
it identical to the thing underneath (same offsets, same border-radius) and
then cut the bottom off with a jagged clip-path:

.t-donut .glaze {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--choco);
  clip-path: polygon(
    0 0, 100% 0, 100% 60%, 92% 55%, 84% 66%, 74% 57%,
    64% 68%, 52% 57%, 40% 68%, 30% 57%, 20% 66%, 10% 55%, 0 64%
  );
}
Enter fullscreen mode Exit fullscreen mode

The drip physically cannot escape the silhouette, because it is the
silhouette. Same trick works for the whole cake's icing and the caramel on the
pudding cup.

Waking the cat without JavaScript

She is a visually hidden checkbox and a label holding her art. Clicking the
label checks the box, which buys click, tap and Space all at once.

The part I am most pleased with is that she goes back to sleep on her own after
about five seconds, so you can wake her again straight away. That is harder than
it sounds, because CSS cannot uncheck a checkbox on a timer. The animation
itself has to return her to sleep.

Which creates a trap. Once she is asleep with the box still checked, the next
click only unchecks it, and if both states name the same animation, the computed
animation-name never changes, so nothing restarts. Half your clicks do
nothing.

The fix is two keyframe blocks that are byte for byte identical, under different
names:

.cat-toggle:checked ~ .shopcat { animation: wake-a 5.4s ease-in-out; }

.cat-toggle:not(:checked) ~ .shopcat:hover,
.cat-toggle:not(:checked):focus ~ .shopcat { animation: wake-b 5.4s ease-in-out; }
Enter fullscreen mode Exit fullscreen mode

A changed animation-name is exactly what restarts a CSS animation. The
unchecked half has to be gated behind :hover or :focus, or it matches on
first paint and she wakes up before anyone has touched her.

Underneath, her whole body runs off one registered custom property:

@property --awake {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
Enter fullscreen mode Exit fullscreen mode

Registering it is what makes it interpolate smoothly instead of snapping at the
halfway point. Then every part of her is a plain function of that one number:

.shopcat .cat-head {
  transform: translateY(calc(var(--awake) * -0.7cqw))
    rotate(calc(var(--awake) * -3deg));
}
.cat-eye::before { opacity: calc(1 - var(--awake)); }  /* sleeping arc */
.cat-eye::after  { opacity: var(--awake); }            /* open eye */
Enter fullscreen mode Exit fullscreen mode

One timeline for the whole cat, so nothing can drift out of sync.

The ball of yarn

CSS has no drag and drop, so this is two checkboxes and three overlapping labels
whose pointer-events get switched by state. The same pixels mean "pick up",
"give" or "take back" depending on where you are in the sequence.

#hold-yarn #give-yarn State
āœ— āœ— on the floor
āœ“ āœ— you are carrying it, and the cat becomes a drop target
āœ“ āœ“ she has it, and hearts float up

The invisible drop zone over the cat is pointer-events: none until you are
actually carrying something, which is what keeps her normally clickable to wake
and only turns her into a target when it makes sense.

The bug that got me

Getting the present to wake her needed a second clock, --gift, running
alongside --awake so the two could overlap instead of fighting over one
animation property. Her poses read whichever is further along:

--lively: max(var(--awake), var(--gift));
Enter fullscreen mode Exit fullscreen mode

I put that on the cat's label and nothing happened. --gift was provably 1 in
DevTools. The animation was definitely running. She just sat there asleep.

Here is why: a custom property is substituted where it is declared, and only
the result inherits.
--gift is animated on a child element, so declaring
--lively on the parent baked in max(<awake>, 0) for every descendant.
Moving the one declaration down to the element that can see both values fixed
it instantly.

That one is going in my notes. It looks completely correct and it fails
silently.

Accessibility

I did not want "pure CSS" to mean "mouse only", so:

  • Every treat is a real <button> with an accessible name that includes its price. Tags reveal on :hover and :focus, because a tap on a phone focuses a button but does not trigger :focus-visible. :focus-visible is used separately for the ring, so a mouse click does not leave one behind.
  • The cat and the yarn are checkboxes, so they work with click, tap and Space, and they show a focus ring on the art itself.
  • On a phone the scene is small and the in-case price tags are tiny, so the full menu is repeated as plain text below the art. That list is the accessible source of truth, and honestly it is the nicer way to read it on mobile anyway.
  • prefers-reduced-motion: reduce switches off every animation. Nothing breaks: the cat becomes a plain two-state toggle, the yarn still moves between floor and paws, and the hearts still show. They just go straight there.

What I picked up

  • @property is genuinely useful for art, not just for gradients. Animating one number and deriving a dozen things from it beats keeping a dozen animations in sync.
  • Container queries make CSS art scale properly with basically no effort.
  • Checkbox and label state machines go further than I expected. Three states and conditional hit targets, no script.
  • Custom property substitution happens at the declaration site. I will not forget that again.
  • Constraints are good. Not being allowed images meant I had to actually think about what a taiyaki looks like as geometry, and I like the result more than if I had just dropped in a PNG.

Thanks for reading. Go click the cat! 🐱

Built With

  • Semantic HTML
  • CSS container queries and cqw units
  • CSS custom properties, including registered @property values
  • clip-path, border-radius and box-shadow for every shape
  • CSS animations and transitions
  • Checkbox and label state machines for all interaction
  • No JavaScript
  • No images, SVG or canvas

Repository

https://github.com/pinkpixel-dev/kitty-cafe

License

Apache 2.0.

Top comments (3)

Collapse
 
hoseinmdev profile image
Hosein Mahmoudi

Hi Jessica, Great post, i follow you in github 😊

Collapse
 
sizzlebop profile image
Jessica Doering

Thank you!!

Collapse
 
nyaomaru profile image
nyaomaru

It's so cute! I'd like to buy Whole Kitty Cake, but I don't have enough money, purr. 😺