Tag: Google

WooCommerce + Google Analytics

Google Analytics is powerful analytics software. A common way to use it is to just slap the JavaScript snippet on every page template you have and let it collect basic data about unique visitors and pageviews and such. That’s useful, but it’s also the bare minimum. Say there is an important button on your site. Leveling up, you could send custom events to track users clicking on that button. Those are the analytics that matter the most.

Further down that road is tracking eCommerce analytics. This is extra-tricky, as it requires you sending events to Google Analytics for sales, instances of adding/removing things from cart, views on products… all sorts of stuff. If you don’t do all that (and do it right), you don’t get good analytics information.

Yet another reason I like WooCommerce! Instead of this analytics integration being a monumental effort and a substantial bit of technical debt to maintain, you just install the WooCommerce Google Analytics plugin and… that’s it. Also: it’s free.

I’ve had this integrated for months right here on CSS-Tricks, and I can confirm:

  1. It was close to zero effort.
  2. It just works.
The plugin installed and activated!
The one bit of config is adding this ID, which is easy to find in Google Analytics, your own code, or another Google Analytics plugin.

Now, to be clear, WooCommerce has its own analytics built right in. If what you are interested in is sales reports and top sellers and stuff like that, those are the dashboards I’d be looking at. But there are some things that the built-in WooCommerce analytics just don’t do. For example, I can check out the sales funnel on Google Analytics now:

30 days of traffic starting from all unique visitors to sessions where they actually bought something.

Somethings this data can be confusing for someone that is not an expert in this area, if this is your case, Google Ads management Melbourne can help you with the woo-commerce of your business.

CSS-Tricks isn’t exactly an eCommerce-focused website, so the funnel there starts super wide and gets super (super) tiny — but hey, at least I can confirm that and see it with my own eyes. Plus, I can glean some insights here, like the fact that 66/70 people completed checkout once they got there (pretty good), but only 70/525 even proceeded to checkout after adding to cart, so I’m losing a lot of people at that stage.

Here is some more interesting data that only Google Analytics knows:

Of 66 sales, 56 of them came from returning visitors, not new visitors. So people tend to not buy on first look, but do come back later. I’m not sure if that means I should be making things more enticing for those new visitors or if I should lean into reminding people about it after they’ve looked at it. Either way, now I know because I have the data.

There is data in the WooCommerce analytics that I’d normally have to go to Google Analytics to see. I can see individual orders. I can see what the top sellers are and compare product sales over different time periods. All useful stuff, and you might appreciate having all this in one place.

Again, my favorite part about this is having all this data. It feels like it should have been hard-won to get, but all it took was clicking a few buttons. That’s why I never regret just doing things the standard WordPress and WooCommerce way! Things tend to just work!

The post WooCommerce + Google Analytics appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, ,

Comparing Google Analytics and Plausible Numbers

I saw this blog post the other day: 58% of Hacker News, Reddit and tech-savvy audiences block Google Analytics. That’s an enticing title to me. I’ve had Google Analytics on this site literally from the day I launched it. While we tend to see some small growth each year, I’m also aware that the ad-blocking usage (and general third-party script blocking) goes up over time. So maybe we have more growth than we can easily visualize in Google Analytics?

The level of Google Analytics blockage varies by industry, audience, the device used and the individual website. In a previous study, I’ve found that less than 10% of visitors block Google Analytics on foodie and lifestyle sites but more than 25% block it on tech-focused sites.

Marko Saric

Marko looked at three days of data on his own website when he had a post trending on both Hacker News and Reddit, and that’s where the 58% came from. Plausible analytics reported 50.9k unique visitors and Google Analytics reported 21.1k. (Google Analytics calls them “Users.”)

I had to try this myself. If we’re literally getting double the traffic Google Analytics says we are, I’d really like to know that. So for the last week, I’ve had Plausible installed on this site (they have a generous unlimited 30-day free trial).

Here’s the Plausible data:

And here’s the Google Analytics data for the same exact period:

It’ll be easier to digest in a table:

Metric Plausible Google Analytics
Unique Visitors 973k 841k
Pageviews 1.4m 1.5m
Bounce Rate 82% 82%
Visit Duration 1m 31s 1m 24s

So… the picture isn’t exactly clear. On one hand, Plausible reports 15% more unique visitors than Google Analytics. Definitely not double, but more! But then, as far as raw traffic is concerned (which actually matters more to me as a site that has ads), Plausible reports 5% less traffic. So it’s still fairly unclear to me what the real story is.

I do think Plausible is a pretty nice bit of software. Easy to install. Simple and nice charts. Very affordable.

Plausible is also third-party JavaScript

I’m sure less people block Plausible’s JavaScript, as, basically, it’s less-known and not Google. But it’s still third-party JavaScript and just as easily blockable as anything else. It’s not a fundamentally different way to measure traffic.

What is fundamentally different is something like Netlify Analytics, where the data comes from server logs that are not blockable (we’ve mentioned this before). That has its own issues, like the fact that Netlify counts bots and Google Analytics doesn’t. Maybe the best bet is something like server-side Google Analytics? That’s just way more technical debt than I’m ready for. Certainly a lot harder than slapping a script tag on the page.

Multiple sources

It felt weird to have multiple analytics tracking on the site at the same time. If I was doing things like tracking custom events, that would get even weirder. If I was going to continue to do it all client-side, I’d probably reach for something like Analytics which is designed to abstract that. But I prefer the idea of sending the data from the client once, and then if it has to go multiple places, do that server-side. That’s basically the entire premise of Segment, which is expensive, but you can self-host or probably write your own without terribly too much trouble. Just seems smart to me to keep less data going over the wire, and having it be first-party JavaScript.

The post Comparing Google Analytics and Plausible Numbers appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, , , ,

What is Your Page Title on a Google Search Engine Results Page?

Whatever Google wants it to be. I always thought it was exactly what your <title> element was. Perhaps in lieu of that, what the first <h1> on the page is. But recently I noticed some pages on this site that were showing a title on SERPs that was a string that appeared nowhere at all in the source of the page.

When I first noticed it, I tweeted my basic findings…

The thread has more info than what unfurled here.

This is a known thing. Apparently, they’ve been doing this for a long time (~10 years), but it’s the first I’ve noticed it. And it’s undergone a recent change:

The article is pretty clear about things:

[…] we think our new system is producing titles that work better for documents overall, to describe what they are about, regardless of the particular query.

Also, while we’ve gone beyond HTML text to create titles for over a decade, our new system is making even more use of such text. In particular, we are making use of text that humans can visually see when they arrive at a web page. We consider the main visual title or headline shown on a page, content that site owners often place within <H1> tags or other header tags, and content that’s large and prominent through the use of style treatments.

Other text contained in the page might be considered, as might be text within links that point at pages.

The change is in response to people having sucky <title> text. Like it’s too long, too jacked up with SEO garbage (irony!), or are just plain non-descriptive.

I’m not entirely sure how much I care just yet.

Part of me thinks, well, google.com isn’t the web. As important as it is, it’s a proprietary product by a private company and they can do whatever they want within the bounds of the law. In this case, it’s clear the intention is to help: to provide titles that are more clear than what the original page has.

Part of me thinks, well, that sucks that, as site owners, we have no control. If Google wanted to change the SERP title for every results to this website to “CSS-Tricks is a stupid website, never visit it,” they could and that’s that.

Part of me connects this kind of work to AMP. AMP was basically saying, “Y’all are absolutely horrible at building performant mobile websites, so we’re going to build a strict set of rules such that you can’t screw it up anymore, and dangling a carrot of better SERP placement if you buy into the rules.” This way of creating page titles is basically saying, “Y’all are absolutely horrible at providing good titles, so we’re going to title your pages for you so you can’t screw it up anymore and we can improve our SERPs.”

Except with AMP, you had to put in the development hours to make it happen. It was opt-in, even if the carrot was un-ignorable by content companies. This doesn’t carry the risk of burning development hours, but it’s also not something we get to opt into.

The post What is Your Page Title on a Google Search Engine Results Page? appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, , , , ,

WooCommerce With Apple Pay and Google Pay

(This is a sponsored post.)

Got a WooCommerce store? It behooves you to offer a variety of payment methods. Just anecdotally, I’m sure both you and me have been annoyed and even abandoned purchases when a merchant, online or otherwise, doesn’t take the payment method we want to pay with. That’s just straight-up lost sales for the merchant. But you don’t have to entirely trust anecdotal evidence, there is data you can pour into, suggesting 7% of abandonment is from missing payment methods.

I’d suggest, at a minimum, you take credit cards and PayPal. There are a variety of payment gateways you can explore (and it’s worth doing so), including a number that take credit cards. The best bet there is WooCommerce Payments — supported in many big countries. It’s Stripe-backed, so it’s a lot like using the Stripe gateway anyway, except way better as it’s loaded with useful features like the fact that you manage all your payments directly in your WordPress dashboard, and Instant Deposits.

The PayPal plugin is free, so that’s kind of a no-brainer, and I’m just talking the basic integration that kicks people over to PayPal.com to pay. Some people like that, as it lets them use their PayPal account online where they may already carry a balance for online purchases and transfers.

The very next step? Apple Pay and Google Pay. Why? Like PayPal, some people strongly prefer it (including me) because of how quick and familiar it makes the checkout process. The Apple Pay and Google Pay functionality in WooCommerce goes so far as to even allow skipping the whole traditional cart and checkout process. That might allow you to make up even more than that 7% based on improved UX.

How does Apple Pay and Google Pay work on WooCommerce? Well if you’re already using WooCommerce Payments, like you should, you’re already almost there.

Enabling Apple Pay and Google Pay on WooCommerce

Apple Pay is supported via the Striple plugin or the Square plugin, but I’d say it’s easiest with WooCommerce Payments. Under Settings > Payments, you’ll see a checkbox for “Enable express checkouts” — flip that on and you’ll be enabling both Apple Pay and Google Pay — and will have an opportunity to pick where you want them to appear.

There are a handful of prerequisites, like having an HTTPS site, but with eCommerce in general, that is not optional and you’ve probably already got it in place.

One thing I experienced when activating it is this warning:

I was able to download the domain association file from the Strip docs, give it to my WordPress Host (Flywheel), and they manually installed it for me and it worked fine.

No “account”

With PayPal, you need a PayPal account for yourself to make it work. That’s not the case with Apple Pay and Google Pay where you don’t have an account and they don’t keep a balance — they just kick that money directly over to WooCommerce Payments and you have access to that money like you would any other WooCommerce Payments transaction.

Example transaction

Here’s an order that came in (I get email notifications for orders):

Notice I can see right in the email that Apple Pay was used.

I can see the order in my dashboard like any other, and have the ability to refund it directly from there and other actions:

I barely even notice it. What payment gateway someone chooses is of little consequence to me once it’s all set up.

The user experience

Apple Pay works on Safari, both on iOS and macOS. If a user both is using one of those browsers and has Apple Pay set up, they’ll see the special buttons show up on your store:

Press that button, and the user sees this immediate checkout step:

The user can change credit cards (that they have set up in Apple Pay), changing shipping address, and then if they approve it, it’s instantly done.

It’s a pretty satisfying user experience, I must say.

Even moreso on a mobile phone, where it feels like things like Apple Pay and Google Pay were really designed to shine. Here’s Apple Pay:

Google Pay works on Android phones nicely, but also works in desktop Chrome.

I did learn one super weird little caveat with Google Pay and desktop Chrome though! Cards that are in your desktop Chrome autofill area in settings that literally say “Google Pay” next to them don’t actually work for the WooCommcere Google Pay buttons. Only credit cards that are kinda manually added in there without that little label work. Just a little thing to be aware of when testing:

This is a rather compelling reason to use WooCommerce for eCommerce. I feel like I got this feature for free. I basically checked a box in settings, and it makes a material positive impact on my business.

The post WooCommerce With Apple Pay and Google Pay appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, ,

Using Google Drive as a CMS

We’re going to walk through the technical process of hooking into Google Drive’s API to source content on a website. We’ll examine the step-by-step implementation, as well as how to utilize server-side caching to avoid the major pitfalls to avoid such as API usage limits and image hotlinking. A ready-to-use npm package, Git repo, and Docker image are provided throughout the article.

But… why?

At some point in the development of a website, a crossroads is reached: how is content managed when the person managing it isn’t technically savvy? If the content is managed by developers indefinitely, pure HTML and CSS will suffice — but this prevents wider team collaboration; besides, no developer wants to be on the hook for content updates in perpetuity.

So what happens when a new non-technical partner needs to gain edit access? This could be a designer, a product manager, a marketing person, a company executive, or even an end customer.

That’s what a good content management system is for, right? Maybe something like WordPress. But this comes with its own set up of disadvantages: it’s a new platform for your team to juggle, a new interface to learn, and a new vector for potential attackers. It requires creating templates, a format with its own syntax and idiosyncrasies. Custom or third-party plugins may need to be to vetted, installed, and configured for unique use cases — and each of these is yet another source of complexity, friction, technical debt, and risk. The bloat of all this setup may end up cramping your tech in a way which is counterproductive to the actual purpose of the website.

What if we could pull content from where it already is? That’s what we’re getting at here. Many of the places where I have worked use Google Drive to organize and share files, and that includes things like blog and landing page content drafts. Could we utilize Google Drive’s API to import a Google Doc directly into a site as raw HTML, with a simple REST request?

Of course we can! Here’s how we did it where I work.

What you’ll need

Just a few things you may want to check out as we get started:

Authenticating with the Google Drive API

The first step is to establish a connection to Google Drive’s API, and for that, we will need to do some kind of authentication. That’s a requirement to use the Drive API even if the files in question are publicly shared (with “link sharing” turned on). Google supports several methods of doing this. The most common is OAuth, which prompts the user with a Google-branded screen saying, “[So-and-so app] wants to access your Google Drive” and waits for user consent — not exactly what we need here, since we’d like to access files in a single central drive, rather than the user’s drive. Plus, it’s a bit tricky to provide access to only particular files or folders. The https://www.googleapis.com/auth/drive.readonly scope we might use is described as:

See and download all your Google Drive files.

That’s exactly what it says on the consent screen. This is potentially alarming for a user, and more to the point, it is a potential security weakness on any central developer/admin Google account that manages the website content; anything they can access is exposed through the site’s CMS back end, including their own documents and anything shared with them. Not good!

Enter the “Service account”

Instead, we can make use of a slightly less common authentication method: a Google service account. Think of a service account like a dummy Google account used exclusively by APIs and bots. However, it behaves like a first-class Google account; it has its own email address, its own tokens for authentication, and its own permissions. The big win here is that we make files available to this dummy service account just like any other user — by sharing the file with the service account’s email address, which looks something like this:


When we go to display a doc or sheet on the website, we simply hit the “Share” button and paste in that email address. Now the service account can see only the files or folders we’ve explicitly shared with it, and that access can be modified or revoked at any time. Perfect!

Creating a service account

A service account can be created (for free) from the Google Cloud Platform Console. That process is well documented in Google’s developer resources, and in addition it’s described in step-by-step detail in the companion repo of this article on GitHub. For the sake of brevity, let’s fast-forward to immediately after a successful authentication of a service account.

The Google Drive API

Now that we’re in, we’re ready to start tinkering with what the Drive API is capable of. We can start from a modified version of the Node.js quickstart sample, adjusted to use our new service account instead of client OAuth. That’s handled in the first several methods of the driveAPI.js we are constructing to handle all of our interactions with the API. The key difference from Google’s sample is in the authorize() method, where we use an instance of jwtClient rather than the oauthClient used in Google’s sample:

authorize(credentials, callback) {   const { client_email, private_key } = credentials;    const jwtClient = new google.auth.JWT(client_email, null, private_key, SCOPES)    // Check if we have previously stored a token.   fs.readFile(TOKEN_PATH, (err, token) => {     if (err) return this.getAccessToken(jwtClient, callback);     jwtClient.setCredentials(JSON.parse(token.toString()));     console.log('Token loaded from file');     callback(jwtClient);   }); }

Node.js vs. client-side

One more note about the setup here — this code is intended to be called from server-side Node.js code. That’s because the client credentials for the service account must be kept secret, and not exposed to users of our website. They are kept in a credentials.json file on the server, and loaded via fs.readFile inside of Node.js. It’s also listed in the .gitignore to keep the sensitive keys out of source control.

Fetching a doc

After the stage is set, loading raw HTML from a Google Doc becomes fairly simple. A method like this returns a Promise of an HTML string:

getDoc(id, skipCache = false) {   return new Promise((resolve, reject) => {     this.drive.files.export({       fileId: id,       mimeType: "text/html",       fields: "data",     }, (err, res) => {       if (err) return reject('The API returned an error: ' + err);       resolve({ html: this.rewriteToCachedImages(res.data) });       // Cache images       this.cacheImages(res.data);     });   }); }

The Drive.Files.export endpoint does all the work for us here. The id we’re passing in is just what shows up in your browsers address bar when you open the doc, which is shown immediately after https://docs.google.com/document/d/.

Also notice the two lines about caching images — this is a special consideration we’ll skip over for now, and revisit in detail in the next section.

Here’s an example of a Google document displayed externally as HTML using this method.

Fetching a sheet

Fetching Google Sheets is almost as easy using Spreadsheets.values.get. We adjust the response object just a little bit to convert it to a simplified JSON array, labeled with column headers from the first row of the sheet.

getSheet(id, range) {   return new Promise((resolve, reject) => {     this.sheets.spreadsheets.values.get({       spreadsheetId: id,     range: range,   }, (err, res) => {     if (err) reject('The API returned an error: ' + err);     // console.log(res.data.values);     const keys = res.data.values[0];     const transformed = [];     res.data.values.forEach((row, i) => {       if(i === 0) return;       const item = {};       row.forEach((cell, index) => {         item[keys[index]] = cell;       });        transformed.push(item);       });       resolve(transformed);     });   }); }

The id parameter is the same as for a doc, and the new range parameter here refers to a range of cells to fetch values from, in Sheets A1 notation.

Example: this Sheet is read and parsed in order to render custom HTML on this page.

…and more!

These two endpoints already get you very far, and forms the backbone of a custom CMS for a website. But, in fact, it only taps the surface of Drive’s potential for content management. It’s also capable of:

  • listing all files in a given folder and display them in a menu,
  • importing complex media from a Google Slides presentation, and
  • downloading and caching custom files.

The only limits here are your creativity, and the constraints of the full Drive API documented here.


As you’re playing with the various kinds of queries that the Drive API supports, you may end up receiving a “User Rate Limit Exceeded” error message . It’s fairly easy to hit this limit through repeated trial-and-error testing during the development phase, and at first glance, it seems as if it would represent a hard blocker for our Google Drive-CMS strategy.

This is where caching comes in — every time we fetch a new version of any file on Drive, we cache it locally (aka server-side, within the Node.js process). Once we do that, we only need to check the version of every file. If our cache is out of date, we fetch the newest version of the corresponding file, but that request only happens once per file version, rather than once per user request. Instead of scaling by the number of people who use the website, we can now scale by the number of updates/edits on Google Drive as our limiting factor. Under the current Drive usage limits on a free-tier account, we could support up to 300 API requests per minute. Caching should keep us well within this limit, and it could be optimized even further by batching multiple requests.

Handling images

The same caching method is applied to images embedded inside Google Docs. The getDoc method parses the HTML response for any image URLs, and makes a secondary request to download them (or fetches them directly from cache if they’re already there). Then it rewrites the original URL in the HTML. The result is that static HTML; we never use hotlinks to Google image CDNs. By the time it gets to the browser, the images have already been pre-cached.

Respectful and responsive

Caching ensures two things: first, that we are being respectful of Google’s API usage limits, and truly utilize Google Drive as a front end for editing and file management (what the tool is intended for), rather than leaching off of it for free bandwidth and storage space. It keeps our website’s interaction with Google’s APIs to the bare minimum necessary to refresh the cache as needed.

The other benefit is one that the users of our website will enjoy: a responsive website with minimal load times. Since cached Google Docs are stored as static HTML on our own server, we can fetch them immediately without waiting for a third-party REST request to complete, keeping website load times to a minimum.

Wrapping in Express

Since all this tinkering has been in server-side Node.js, we need a way for our client pages to interact with the APIs. By wrapping the DriveAPI into its own REST service, we can create a middleman/proxy service which abstracts away all the logic of caching/fetching new versions, while keeping the sensitive authentication credentials safe on the server side.

A series of express routes, or the equivalent in your favorite web server, will do the trick, with a series of routes like this:

const driveAPI = new (require('./driveAPI'))(); const express = require('express'); const API_VERSION = 1; const router = express.Router();  router.route('/getDoc') .get((req, res) => {   console.log('GET /getDoc', req.query.id);   driveAPI.getDoc(req.query.id)   .then(data => res.json(data))   .catch(error => {     console.error(error);     res.sendStatus(500);   }); });  // Other routes included here (getSheet, getImage, listFiles, etc)...  app.use(`/api/v$  {API_VERSION}`, router);

See the full express.js file in the companion repo.

Bonus: Docker Deployment

For deployment to production, we can can run the Express server alongside your existing static web server. Or, if it’s convenient, we could easily wrap it in a Docker image:

FROM node:8 # Create app directory WORKDIR /usr/src/app # Install app dependencies # A wildcard is used to ensure both package.json AND package-lock.json are copied # where available (npm@5+) COPY package*.json ./ RUN npm install # If you are building your code for production # RUN npm ci --only=production # Bundle app source COPY . . CMD [ "node", "express.js" ]

…or use this pre-built image published on Docker Hub.

Bonus 2: NGINX Google OAuth

If your website is public-facing (accessible by anyone on the internet), then we’re done! But for our purposes where I work at Motorola, we are publishing an internal-only documentation site that needs additional security. That means Link Sharing is turned off on all our Google Docs (they also happened to be stored in an isolated and dedicated Google Team Drive separated from all other company content).

We handled this additional layer of security as early as possible at the server level, using NGINX to intercept and reverse-proxy all requests before they even make it to the Express server or any static content hosted by the website. For this, we use Cloudflare’s excellent Docker image to present a Google sign-on screen to all employees accessing any website resources or endpoints (both the Drive API Express server and the static content alongside it). It seamlessly integrates with the corporate Google account and single-sign-on they already have — no extra account needed!


Everything we just covered in this article is exactly what we’ve done where I work. It’s a lightweight, flexible, and decentralized content management architecture, in which the raw data lives where Google Drive, where our team already works, using a UI that’s already familiar to everyone. It all gets tied together into the website’s front end which retains the full flexibility of pure HTML and CSS in terms of control over presentation, and with minimal architectural constraints. A little extra legwork from you, the developer, creates a virtually seamless experience for both your non-dev collaborators and your end users alike.

Will this sort of thing work for everyone? Of course not. Different sites have different needs. But if I were to put together a list of use cases for when to use Google Drive as a CMS, it would look something like this:

  • An internal site with between a few hundred to a few thousand daily users — If this had been the front page of the global company website, even a single request for file version metadata per user might approach that Drive API usage limit. Further techniques could help mitigate that — but it’s the best fit for small to medium-size websites.
  • A single-page app — This setup has allowed us to query the version numbers of every data source in a single REST request, one time per session, rather than one time per page. A non-single-page app could use the same approach, perhaps even making use of cookies or local storage to accomplish the same “once per visit” version query, but again, it would take a little extra legwork.
  • A team that’s already using Google Drive — Perhaps most important of all, our collaborators were pleasantly surprised that they could contribute to the website using an account and workflow they already had access to and were comfortable using, including all of the refinements of Google’s WYSIWYG experience, powerful access management, and the rest.

The post Using Google Drive as a CMS appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.


, ,

What I Learned Building a Word Game App With Nuxt on Google Play

I fell in love with coding the moment I created my first CSS :hover effect. Years later, that initial bite into interactivity on the web led me to a new goal: making a game.

Those early moments playing with :hover were nothing special, or even useful. I remember making a responsive grid of blue squares (made with float, if that gives you an idea of the timeline), each of which turned orange when the cursor moved over them. I spent what felt like hours mousing over the boxes, resizing the window to watch them change size and alignment, then doing it all over again. It felt like pure magic.

What I built on the web naturally became more complex than that grid of <div> elements over the years, but the thrill of bringing something truly interactive to life always stuck with me. And as I learned more and more about JavaScript, I especially loved making games.

Sometimes it was just a CodePen demo; sometimes it was a small side project deployed on Vercel or Netlify. I loved the challenge of recreating games like color flood, hangman, or Connect Four in a browser.

After a while, though, the goal got bigger: what if I made an actual game? Not just a web app; a real live, honest-to-goodness, download-from-an-app-store game. Last August, I started working on my most ambitious project to date, and four months later, I released it to the world (read: got tired of fiddling with it): a word game app that I call Quina.

What’s the game (and what’s that name)?

The easiest way to explain Quina is: it’s Mastermind, but with five-letter words. In fact, Mastermind is actually a version of a classic pen-and-paper game; Quina is simply another variation on that same original game.

The object of Quina is to guess a secret five-letter word. After each guess, you get a clue that tells you how close your guess is to the code word. You use that clue to refine your next guess, and so on, but you only get ten total guesses; run out and you lose.

Example Quina gameplay

The name “Quina” came about because it means “five at a time” in Latin (or so Google told me, anyway). The traditional game is usually played with four-letter words, or sometimes four digits (or in the case of Mastermind, four colors); Quina uses five-letter words with no repeated letters, so it felt fitting that the game should have a name that plays by its own rules. (I have no idea how the original Latin word was pronounced, but I say it “QUINN-ah,” which is probably wrong, but hey, it’s my game, right?)

I spent my evenings and weekends over the course of about four months building the app. I’d like to spend this article talking about the tech behind the game, the decisions involved, and lessons learned in case this is a road you’re interested in traveling down yourself.

Choosing Nuxt

I’m a huge fan of Vue, and wanted to use this project as a way to expand my knowledge of its ecosystem. I considered using another framework (I’ve also built projects in Svelte and React), but I felt Nuxt hit the sweet spot of familiarity, ease of use, and maturity. (By the way, if you didn’t know or hadn’t guessed: Nuxt could be fairly described as the Vue equivalent of Next.js.)

I hadn’t gone too deep with Nuxt previously; just a couple of very small apps. But I knew Nuxt can compile to a static app, which is just what I wanted — no (Node) servers to worry about. I also knew Nuxt could handle routing as easily as dropping Vue components into a /pages folder, which was very appealing.

Plus, though Vuex (the official state management in Vue) isn’t terribly complex on its own, I appreciated the way that Nuxt adds just a little bit of sugar to make it even simpler. (Nuxt makes things easy in a variety of ways, by the way, such as not requiring you to explicitly import your components before you can use them; you can just put them in the markup and Nuxt will figure it out and auto-import as needed.)

Finally, I knew ahead of time I was building a Progressive Web App (PWA), so the fact that there’s already a Nuxt PWA module to help build out all the features involved (such as a service worker for offline capability) already packaged up and ready to go was a big draw. In fact, there’s an impressive array of Nuxt modules available for any unseen hurdles. That made Nuxt the easiest, most obvious choice, and one I never regretted.

I ended up using more of the modules as I went, including the stellar Nuxt Content module, which allows you to write page content in Markdown, or even a mixture of Markdown and Vue components. I used that feature for the “FAQs” page and the “How to Play” page as well (since writing in Markdown is so much nicer than hard-coding HTML pages).

Achieving native app feel with the web

Quina would eventually find a home on the Google Play Store, but regardless of how or where it was played, I wanted it to feel like a full-fledged app from the get-go.

To start, that meant an optional dark mode, and a setting to reduce motion for optimal usability, like many native apps have (and in the case of reduced motion, like anything with animations should have).

Under the hood, both of the settings are ultimately booleans in the app’s Vuex data store. When true, the setting renders a specific class in the app’s default layout. Nuxt layouts are Vue templates that “wrap” all of your content, and render on all (or many) pages of your app (commonly used for things like shared headers and footers, but also useful for global settings):

<!-- layouts/default.vue --> <template>   <div     :class="[       {         'dark-mode': darkMode,         'reduce-motion': reduceMotion,       },       'dots',     ]"   >     <Nuxt />   </div> </template>  <script> import { mapGetters } from 'vuex'  export default {   computed: {     ...mapGetters(['darkMode', 'reduceMotion']),   },   // Other layout component code here } </script>

Speaking of settings: though the web app is split into several different pages — menu, settings, about, play, etc. — the shared global Vuex data store helps to keep things in sync and feeling seamless between areas of the app (since the user will adjust their settings on one page, and see them apply to the game on another).

Every setting in the app is also synced to both localStorage and the Vuex store, which allows saving and loading values between sessions, on top of keeping track of settings as the user navigates between pages.

And speaking of navigation: moving between pages is another area where I felt there was a lot of opportunity to make Quina feel like a native app, by adding full-page transitions.

Nuxt page transitions in action

Vue transitions are fairly straightforward in general—you just write specifically-named CSS classes for your “to” and “from” transition states—but Nuxt goes a step further and allows you to set full page transitions with only a single line in a page’s Vue file:

<!-- A page component, e.g., pages/Options.vue --> <script> export default {   transition: 'page-slide'   // ... The rest of the component properties } </script>

That transition property is powerful; it lets Nuxt know we want the page-slide transition applied to this page whenever we navigate to or away from it. From there, all we need to do is define the classes that handle the animation, as you would with any Vue transition. Here’s my page-slide SCSS:

/* assets/css/_animations.scss */  .page-slide {   &-enter-active {     transition: all 0.35s cubic-bezier(0, 0.25, 0, 0.75);   }    &-leave-active {     transition: all 0.35s cubic-bezier(0.75, 0, 1, 0.75);   }    &-enter,   &-leave-to {     opacity: 0;     transform: translateY(1rem);      .reduce-motion & {       transform: none !important;     }   }    &-leave-to {     transform: translateY(-1rem);   } }

Notice the .reduce-motion class; that’s what we talked about in the layout file just above. It prevents visual movement when the user has indicated they prefer reduced motion (either via media query or manual setting), by disabling any transform properties (which seemed to warrant usage of the divisive !important flag). The opacity is still allowed to fade in and out, however, since this isn’t really movement.

Comparing default motion (left) with reduced motion (right)

Side note on transitions and handling 404s: The transitions and routing are, of course, handled by JavaScript under the hood (Vue Router, to be exact), but I ran into a frustrating issue where scripts would stop running on idle pages (for example, if the user left the app or tab open in the background for a while). When coming back to those idle pages and clicking a link, Vue Router would have stopped running, and so the link would be treated as relative and 404.

Example: the /faq page goes idle; the user comes back to it and clicks the link to visit the /options page. The app would attempt to go to /faq/options, which of course doesn’t exist.

My solution to this was a custom error.vue page (this is a Nuxt page that automatically handles all errors), where I’d run validation on the incoming path and redirect to the end of the path.

// layouts/error.vue mounted() {   const lastPage = '/' + this.$ route.fullPath.split('/').pop()   // Don't create a redirect loop   if (lastPage !== this.$ route.fullPath) {     this.$ router.push({       path: lastPage,     })   } }

This worked for my use case because a) I don’t have any nested routes; and b) at the end of it, if the path isn’t valid, it still hits a 404.

Vibration and sound

Transitions are nice, but I also knew Quina wouldn’t feel like a native app — especially on a smartphone — without both vibration and sound.

Vibration is relatively easy to achieve in browsers these days, thanks to the Navigator API. Most modern browsers simply allow you to call window.navigator.vibrate() to give the user a little buzz or series of buzzes — or, using a very short duration, a tiny bit of tactile feedback, like when you tap a key on a smartphone keyboard.

Obviously, you want to use vibration sparingly, for a few reasons. First, because too much can easily become a bad user experience; and second, because not all devices/browsers support it, so you need to be very careful about how and where you attempt to call the vibrate() function, lest you cause an error that shuts down the currently running script.

Personally, my solution was to set a Vuex getter to verify that the user is allowing vibration (it can be disabled from the settings page); that the current context is the client, not the server; and finally, that the function exists in the current browser. (ES2020 optional chaining would have worked here as well for that last part.)

// store/getters.js vibration(state) {   if (     process.client &&     state.options.vibration &&     typeof window.navigator.vibrate !== 'undefined'   ) {     return true   }   return false },

Side note: Checking for process.client is important in Nuxt — and many other frameworks with code that may run on Node — since window won’t always exist. This is true even if you’re using Nuxt in static mode, since the components are validated in Node during build time. process.client (and its opposite, process.server ) are Nuxt niceties that just validate the code’s current environment at runtime, so they’re perfect for isolating browser-only code.

Sound is another key part of the app’s user experience. Rather than make my own effects (which would’ve undoubtedly added dozens more hours to the project), I mixed samples from a few artists who know better what they’re doing in that realm, and who offered some free game sounds online. (See the app’s FAQs for full info.)

Users can set the volume they prefer, or shut the sound off entirely. This, and the vibration, are also set in localStorage on the user’s browser as well as synced to the Vuex store. This approach allows us to set a “permanent” setting saved in the browser, but without the need to retrieve it from the browser every time it’s referenced. (Sounds, for example, check the current volume level each time one is played, and the latency of waiting on a localStorage call every time that happens could be enough to kill the experience.)

An aside on sound

It turns out that for whatever reason, Safari is extremely laggy when it comes to sound. All the clicks, boops and dings would take a noticeable amount of time after the event that triggered them to actually play in Safari, especially on iOS. That was a deal-breaker, and a rabbit hole I spent a good amount of hours despairingly tunneling down.

Fortunately, I found a library called Howler.js that solves cross-platform sound issues quite easily (and that also has a fun little logo). Simply installing Howler as a dependency and running all of the app’s sounds through it — basically one or two lines of code — was enough to solve the issue.

The Howler.js logo, featuring a cute little howler monkey wearing headphones

If you’re building a JavaScript app with synchronous sound, I’d highly recommend using Howler, as I have no idea what Safari’s issue was or how Howler solves it. Nothing I tried worked, so I’m happy just having the issue resolved easily with very little overhead or code modification.

Gameplay, history, and awards

Quina can be a difficult game, especially at first, so there are a couple of ways to adjust the difficulty of the game to suit your personal preference:

  1. You can choose what kind of words you want to get as code words: Basic (common English words), Tricky (words that are either more obscure or harder to spell), or Random (a weighted mix of the two).
  2. You can choose whether to receive a hint at the start of each game, and if so, how much that hint reveals.
Quina offers several different ways to play, to accommodate players of all skill levels

These settings allow players of various skill, age, and/or English proficiency to play the game on their own level. (A Basic word set with strong hints would be the easiest; Tricky or Random with no hints would be the hardest.)

“Soft hints” reveal one letter in the code word (but not its position)

While simply playing a series of one-off games with adjustable difficulty might be enjoyable enough, that would feel more like a standard web app or demo than a real, full-fledged game. So, in keeping with the pursuit of that native app feel, Quina tracks your game history, shows your play statistics in a number of different ways, and offers several “awards” for various achievements.

Quina tracks the results of all games played, your longest win streaks, and many other stats

Under the hood, each game is saved as an object that looks something like this:

{   guessesUsed: 3,   difficulty: 'tricky',   win: true,   hint: 'none', }

The app catalogues your games played (again, via Vuex state synced to localStorage) in the form of a gameHistory array of game objects, which the app then uses to display your stats — such as your win/loss ratio, how many games you’ve played, and your average guesses — as well as to show your progress towards the game’s “awards.”

This is all done easily enough with various Vuex getters, each of which utilizes JavaScript array methods, like .filter() and .reduce(), on the gameHistory array. For example, this is the getter that shows how many games the user has won while playing on the “tricky” setting:

// store/getters.js trickyGamesWon(state) {   return state.gameHistory.filter(     (game) => game.win && game.difficulty === 'tricky'   ).length },

There are many other getters of varying complexity. (The one to determine the user’s longest win streak was particularly gnarly.)

Adding awards was a matter of creating an array of award objects, each tied to a specific Vuex getter, and each with a requirement.threshold property indicating when that award was unlocked (i.e., when the value returned by the getter was high enough). Here’s a sample:

// assets/js/awards.js export default [   {     title: 'Onset',     requirement: {       getter: 'totalGamesPlayed',       threshold: 1,       text: 'Play your first game of Quina',     }   },   {     title: 'Sharp',     requirement: {       getter: 'trickyGamesWon',       threshold: 10,       text: 'Win ten total games on Tricky',     },   }, ]

From there, it’s a pretty straightforward matter of looping over the achievements in a Vue template file to get the final output, using its requirement.text property (though there’s a good deal of math and animation added to fill the gauges to show the user’s progress towards achieving the award):

Awards are unlocked and displayed if a player’s progress is above the threshold; otherwise, the game displays the progress bar with an indication of how much is left until the award is unlocked.

There are 25 awards in all (that’s 5 × 5, in keeping with the theme) for various achievements like winning a certain number of games, trying out all the game modes, or even winning a game within your first three guesses. (That one is called “Lucky” — as an added little Easter egg, the name of each award is also a potential code word, i.e., five letters with no repeats.)

Unlocking awards doesn’t do anything except give you bragging rights, but some of them are pretty difficult to achieve. (It took me a few weeks after releasing to get them all!)

Pros and cons of this approach

There’s a lot to love about the “build once, deploy everywhere” strategy, but it also comes with some drawbacks:


  • You only need to deploy your store app once. After that, all updates can just be website deploys. (This is much quicker than waiting for an app store release.)
  • Build once. This is sorta true, but turned out to be not quite as straightforward as I thought due to Google’s payments policy (more on that later).
  • Everything is a browser. Your app is always running in the environment you’re used to, whether the user realizes it or not.


  • Event handlers can get really tricky. Since your code is running on all platforms simultaneously, you have to anticipate any and all types of user input at once. Some elements in the app can be tapped, clicked, long-pressed, and also respond differently to various keyboard keys; it can be tricky to handle all of those at once without any of the handlers stepping on each other’s toes.
  • You may have to split experiences. This will depend on what your app is doing, but there were some things I needed to show only for users on the Android app and others that were only for web. (I go into a little more detail on how I solved this in another section below.)
  • Everything is a browser. You’re not worried about what version of Android your users are on, but you are worried about what their default browser is (because the app will use their default browser behind the scenes). Typically on Android this will mean Chrome, but you do have to account for every possibility.

Logistics: turning a web app into a native app

There’s a lot of technology out there that makes the “build for the web, release everywhere” promise — React Native, Cordova, Ionic, Meteor, and NativeScript, just to name a few.

Generally, these boil down to two categories:

  1. You write your code the way a framework wants you to (not exactly the way you normally would), and the framework transforms it into a legitimate native app;
  2. You write your code the usual way, and the tech just wraps a native “shell” around your web tech and essentially disguises it as a native app.

The first approach may seem like the more desirable of the two (since at the end of it all you theoretically end up with a “real” native app), but I also found it comes with the biggest hurdles. Every platform or product requires you to learn its way of doing things, and that way is bound to be a whole ecosystem and framework unto itself. The promise of “just write what you know” is a pretty strong overstatement in my experience. I’d guess in a year or two a lot of those problems will be solved, but right now, you still feel a sizable gap between writing web code and shipping a native app.

On the other hand, the second approach is viable because of a thing called “TWA,” which is what makes it possible to make a website into an app in the first place.

What is a TWA app?

TWA stands for Trusted Web Activity — and since that answer is not likely to be helpful at all, let’s break that down a bit more, shall we?

A TWA app basically turns a website (or web app, if you want to split hairs) into a native app, with the help of a little UI trickery.

You could think of a TWA app as a browser in disguise. It’s an Android app without any internals, except for a web browser. The TWA app is pointed to a specific web URL, and whenever the app is booted, rather than doing normal native app stuff, it just loads that website instead  —  full-screen, with no browser controls, effectively making the website look and behave as though it were a full-fledged native app.

TWA requirements

It’s easy to see the appeal of wrapping up a website in a native app. However, not just any old site or URL qualifies; in order to launch your web site/app as a TWA native app, you’ll need to check the following boxes:

  • Your site/app must be a PWA. Google offers a validation check as part of Lighthouse, or you can check with Bubblewrap (more on that in a bit).
  • You must generate the app bundle/APK yourself; it’s not quite as easy as just submitting the URL of your progressive web app and having all the work done for you. (Don’t worry; we’ll cover a way to do this even if you know nothing about native app development.)
  • You must have a matching secure key, both in the Android app and uploaded to your web app at a specific URL.

That last point is where the “trusted” part comes in; a TWA app will check its own key, then verify that the key on your web app matches it, to ensure it’s loading the right site (presumably, to prevent malicious hijacking of app URLs). If the key doesn’t match or isn’t found, the app will still work, but the TWA functionality will be gone; it will just load the web site in a plain browser, chrome and all. So the key is extremely important to the experience of the app. (You could say it’s a key part. Sorry not sorry.)

Advantages and drawbacks of building a TWA app

The main advantage of a TWA app is that it doesn’t require you to change your code at all — no framework or platform to learn; you’re just building a website/web app like normal, and once you’ve got that done, you’ve basically got the app code done, too.

The main drawback, however, is that (despite helping to usher in the modern age of the web and JavaScript), Apple is not in favor of TWA apps; you can’t list them in the Apple App store. Only Google Play.

This may sound like a deal-breaker, but bear a few things in mind:

  • Remember, to list your app in the first place, it needs to be a PWA — which means it’s installable by default. Users on any platform can still add it to their device’s home screen from the browser. It doesn’t need to be in the Apple App Store to be installed on Apple devices (though it certainly misses out on the discoverability). So you could still build a marketing landing page into your app and prompt users to install it from there.
  • There’s also nothing to prevent you from developing a native iOS app using a completely different strategy. Even if you wanted both iOS and Android apps, as long as a web app is also part of the plan, having a TWA effectively cuts out half of that work.
  • Finally, while iOS has about a 50% market share in predominantly English-speaking countries and Japan, Android has well over 90% of the rest of the world. So, depending on your audience, missing out on the App Store may not be as impactful as you might think.

How to generate the Android App APK

At this point you might be saying, this TWA business sounds all well and good, but how do I actually take my site/app and shove it into an Android app?

The answer comes in the form of a lovely little CLI tool called Bubblewrap.

You can think of Bubblewrap as a tool that takes some input and options from you, and generates an Android app (specifically, an APK, one of the file formats allowed by the Google Play Store) out of the input.

Installing Bubblewrap is a little tricky, and while using it is not quite plug-and-play, it’s definitely far more within reach for an average front-end dev than any other comparable options that I found. The README file on Bubblewrap’s NPM page goes into the details, but as a brief overview:

Install Bubblewrap by running npm i -g @bubblewrap/cli (I’m assuming here you’re familiar with NPM and installing packages from it via the command line). That will allow you to use Bubblewrap anywhere.

Once it’s installed, you’ll run:

bubblewrap init --manifest https://your-webapp-domain/manifest.json

Note: the manifest.json file is required of all PWAs, and Bubblewrap needs the URL to that file, not just your app. Also be warned: depending on how your manifest file is generated, its name may be unique to each build. (Nuxt’s PWA module appends a unique UUID to the file name, for example.)

Also note that by default, Bubblewrap will validate that your web app is a valid PWA as part of this process. For some reason, when I was going through this process, the check kept coming back negative, despite Lighthouse confirming that it was in fact a fully functional progressive web app. Fortunately, Bubblewrap allows you to skip this check with the --skipPwaValidation flag.

If this is your first time using Bubblewrap, it will then ask if you want it to install the Java Development Kit (JDK) and Android Software Development Kit (SDK) for you. These two are the behind-the-scenes utilities required to generate an Android app. If you’re not sure, hit “Y” for yes.

Note: Bubblewrap expects these two development kits to exist in very specific locations, and won’t work properly if they’re not there. You can run bubblewrap doctor to verify, or see the full Bubblewrap CLI README.

After everything’s installed — assuming it finds your manifest.json file at the provided URL — Bubblewrap will ask some questions about your app.

Many of the questions are either preference (like your app’s main color) or just confirming basic details (like the domain and entry point for the app), and most will be pre-filled from your site’s manifest file.

Other questions that may already be pre-filled by your manifest include where to find your app’s various icons (to use as the home screen icon, status bar icon, etc.), what color the splash screen should be while the app is opening, and the app’s screen orientation, in case you want to force portrait or landscape. Bubblewrap will also ask if you want to request permission for your user’s geolocation, and whether you’re opting into Play Billing.

However, there are a few important questions that may be a little confusing, so let’s cover those here:

  • Application ID: This appears to be a Java convention, but each app needs a unique ID string that’s generally 2–3 dot-separated sections (e.g., collinsworth.quina.app). It doesn’t actually matter what this is; it’s not functional, it’s just convention. The only important thing is that you remember it, and that it’s unique. But do note that this will become part of your app’s unique Google Play Store URL. (For this reason, you cannot upload a new bundle with a previously used App ID, so make sure you’re happy with your ID.)
  • Starting version: This doesn’t matter at the moment, but the Play Store will require you to increment the version as you upload new bundles, and you cannot upload the same version twice. So I’d recommend starting at 0 or 1.
  • Display mode: There are actually a few ways that TWA apps can display your site. Here, you most likely want to choose either standalone (full-screen, but with the native status bar at the top), or fullscreen (no status bar). I personally chose the default standalone option, as I didn’t see any reason to hide the user’s status bar in-app, but you might choose differently depending on what your app does.

The signing key

The final piece of the puzzle is the signing key. This is the most important part. This key is what connects your progressive web app to this Android app. If the key the app is expecting doesn’t match what’s found in your PWA, again: your app will still work, but it will not look like a native app when the user opens it; it’ll just be a normal browser window.

There are two approaches here that are a little too complex to go into in detail, but I’ll try to give some pointers:

  1. Generate your own keystore. You can have Bubblewrap do this, or use a CLI tool called keytool (appropriately enough), but either way: be very careful. You need to explicitly track the exact name and passwords for your keystores, and since you’re creating both on the command line, you need to be extremely careful of special characters that could mess up the whole process. (Special characters may be interpreted differently on the command line, even when input as part of a password prompt.)
  2. Allow Google to handle your keys. This honestly isn’t dramatically simpler in my experience, but it saves some of the trouble of wrangling your own signing keys by allowing you to go into the Google Play Developer console, and download a pre-generated key for your app.

Whichever option you choose, there’s in-depth documentation on app signing here (written for Android apps, but most of it is still relevant).

The part where you get the key onto your personal site is covered in this guide to verifying Android app links. To crudely summarize: Google will look for a /.well-known/assetlinks.json file at that exact path on your site. The file needs to contain your unique key hash as well as a few other details:

[{   "relation": ["delegate_permission/common.handle_all_urls"],   "target" : { "namespace": "android_app", "package_name": "your.app.id",                "sha256_cert_fingerprints": ["your:unique:hash:here"] } }]

What you should know about listing an app

Before you get started, there are also some hurdles to be aware of on the app store side of things:

  • First and foremost, you need to sign up before you can publish to the Google Play Store. This eligibility costs a one-time $ 25 USD fee.
  • Once approved, know that listing an app is neither quick nor easy. It’s more tedious than difficult or technical, but Google reviews every single app and update on the store, and requires you to fill out a lot of forms and info about both yourself and your app before you can even start the review process — which itself can take many days, even if your app isn’t even public yet. (Friendly heads-up: there’s been a “we’re experiencing longer than usual review times” warning banner in the Play console dashboard for at least six months now.)
    • Among the more tedious parts: you must upload several images of your app in action before your review can even begin. These will eventually become the images shown in the store listing — and bear in mind that changing them will also kick off a new review, so come to the table prepared if you want to minimize turnaround time.
    • You also need to provide links to your app’s terms of service and privacy policy (which is the only reason my app even has them, since they’re all but pointless).
    • There are lots of things you can’t undo. For example, you can never change a free app to paid, even if it hasn’t publicly launched yet and/or has zero downloads. You also have to be strict on versioning and naming with what you upload, because Google doesn’t let you overwrite or delete your apps or uploaded bundles, and doesn’t always let you revert other settings in the dashboard, either. If you have a “just jump in and work out the kinks later” approach (like me), you may find yourself starting over from scratch at least once or twice.
  • With a few exceptions, Google has extremely restrictive policies about collecting payments in an app. When I was building, it was charging a 30% fee on all transactions (they’ve since conditionally lowered that to 15% — better, but still five times more than most other payment providers would charge). Google also forces developers (with a few exceptions) to use its own native payment platform; no opting for Square, Stripe, PayPal, etc. in-app.
    • Fun fact: this policy had been announced but wasn’t in effect yet while I was trying to release Quina, and it still got flagged by the reviewer for being in violation. So they definitely take this policy very seriously.

Monetization, unlockables, and getting around Google

While my goal with Quina was mostly personal — challenge myself, prove I could, and learn more about the Vue ecosystem in a complex real-world app — I had also hoped as a secondary goal that my work might be able to make a little money on the side for me and my family.

Not a lot. I never had illusions of building the next Candy Crush (nor the ethical void required to engineer an addiction-fueled micro-transaction machine). But since I had poured hundreds of hours of my time and energy into the game, I had hoped that maybe I could make something in return, even if it was just a little beer money.

Initially, I didn’t love the idea of trying to sell the app or lock its content, so I decided to add a simple “would you care to support Quina if you like it?” prompt after every so many games, and make some of the content unlockable specifically for supporters. (Word sets are limited in size bu default, and some game settings are initially locked as well.) The prompt to support Quina can be permanently dismissed (I’m not a monster), and any donation unlocks everything; no tiered access or benefits.

This was all fairly straightforward to implement thanks to Stripe, even without a server; it’s all completely client-side. I just import a bit of JavaScript on the /support page, using Nuxt’s handy head function (which adds items to the <head> element specifically on the given page):

// pages/support.vue head() {   return {     script: [       {         hid: 'stripe',         src: 'https://js.stripe.com/v3',         defer: true,         callback: () => {           // Adds all Stripe methods like redirectToCheckout to page component           this.stripe = Stripe('your_stripe_id')         },       },     ],   } },

With that bit in place (along with a sprinkle of templating and logic), users can choose their donation amount — set up as products on the Stripe side — and be redirected to Stripe to complete payment, then returned when finished. For each tier, the return redirect URL is slightly different via query parameters. Vue Router parses the URL to adjust the user’s stored donation history, and unlock features accordingly.

You might wonder why I’m revealing all of this, since it exposes the system as fairly easy to reverse-engineer. The answer is: I don’t care. In fact, I added a free tier myself, so you don’t even have to go to the trouble. I decided that if somebody really wanted the unlockables but couldn’t or wouldn’t pay for whatever reason, that’s fine. Maybe they live in a situation where $ 3 is a lot of money. Maybe they gave on one device already. Maybe they’ll do something else nice instead. But honestly, even if their intentions aren’t good: so what?

I appreciate support, but this isn’t my living, and I’m not trying to build a dopamine tollbooth. Besides, I’m not personally comfortable with the ethical implications of using a stack of totally open-source and/or free software (not to mention the accompanying mountain of documentation, blog posts, and Stack Overflow answers written about all of it) to build a closed garden for personal profit.

So, if you like Quina and can support it: sincerely, thank you. That means a ton to me. I love to see my work being enjoyed. But if not: that’s cool. If you want the “free” option, it’s there for you.

Anyway, this whole plan hit a snag when I learned about Google Play’s new monetization policy, effective this year. You can read it yourself, but to summarize: if you make money through a Google Play app and you’re not a nonprofit, you gotta go through Google Pay and pay a hefty fee — you are not allowed to use any other payment provider.

This meant I couldn’t even list the app; it would be blocked just for having a “support” page with payments that don’t go through Google. (I suppose I probably could have gotten around this by registering a nonprofit, but that seemed like the wrong way to go about it, on a number of levels.)

My eventual solution was to charge for the app itself on Google Play, by listing it for $ 2.99 (rather than my previously planned price of “free”), and simply altering the app experience for Android users accordingly.

Customizing the app experience for Google Play

Fortunately enough, Android apps send a custom header with the app’s unique ID when requesting a website. Using this header, it was easy enough to differentiate the app’s experience on the web and in the actual Android app.

For each request, the app checks for the Android ID; if present, the app sets a Vuex state boolean called isAndroid to true. This state cascades throughout the app, working to trigger various conditionals to do things like hide and show various FAQ questions, and (most importantly) to hide the support page in the nav menu. It also unlocks all content by default (since the user’s already “donated” on Android, by purchasing). I even went so far as to make simple <WebOnly> and <AndroidOnly> Vue wrapper components to wrap content only meant for one of the two. (Obviously, users on Android who can’t visit the support page shouldn’t see FAQs on the topic, as an example.)

<!-- /src/components/AndroidOnly.vue --> <template>   <div v-if="isAndroid">     <slot />   </div> </template>  <script> export default {   computed: {     isAndroid() {       return this.$ store.state.isAndroid     },   }, } </script>

Accounting for accounts

For a time while building Quina, I had Firebase set up for logins and storing user data. I really liked the idea of allowing users to play on all their devices and track their stats everywhere, rather than have a separate history on each device/browser.

In the end, however, I scrapped that idea, for a few reasons. One was complexity; it’s not easy maintaining a secure accounts system and database, even with a nice system like Firebase, and that kind of overhead isn’t something I took lightly. But mainly: the decision boiled down to security and simplicity.

At the end of the day, I didn’t want to be responsible for users’ data. Their privacy and security is guaranteed by using localStorage, at the small cost of portability. I hope players don’t mind the possibility of losing their stats from time to time if it means they have no login or data to worry about. (And hey, it also gives them a chance to earn those awards all over again.)

Plus, it just feels nice. I get to honestly say there’s no way my app can possibly compromise your security or data because it knows literally nothing about you. And also, I don’t need to worry about compliance or cookie warnings or anything like that, either.

Wrapping up

Building Quina was my most ambitious project to date, and I had as much fun designing and engineering it as I have seeing players enjoy it.

I hope this journey has been helpful for you! While getting a web app listed in the Google Play Store has a lot of steps and potential pitfalls, it’s definitely within reach for a front-end developer. I hope you take this story as inspiration, and if you do, I’m excited to see what you build with your newfound knowledge.

The post What I Learned Building a Word Game App With Nuxt on Google Play appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , , , ,

Comparing Data in Google and Netlify Analytics

Jim Nielsen:

the datasets weren’t even close for me.

Google Analytics works by putting a client-side bit of JavaScript on your site. Netlify Analytics works by parsing server logs server-side. They are not exactly apples to apples, feature-wise. Google Analytics is, I think it’s fair to say, far more robust. You can do things like track custom events which might be very important analytics data to a site. But they both have the basics. They both want to tell you how many pageviews your homepage got, for instance.

There are two huge things that affect these numbers:

  • Client-side JavaScript is blockable and tons of people use content blockers, particularly for third-party scripts from Google. Server-side logs are not blockable.
  • Netlify doesn’t filter things out of that log, meaning bots are counted in addition to regular people visiting.

So I’d say: Netlify probably has more accurate numbers, but a bit inflated from the bots.

Also worth noting, you can do server-side Google Analytics. I’ve never seen anyone actually do it but it seems like a good idea.

One bit of advice from Jim:

Never assume too much from a single set of data. In other words, don’t draw all your data-driven insights from one basket.

Direct Link to ArticlePermalink

The post Comparing Data in Google and Netlify Analytics appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , ,

Getting the Most Out of Variable Fonts on Google Fonts

I have spent the past several years working (alongside a bunch of super talented people) on a font family called Recursive Sans & Mono, and it just launched officially on Google Fonts!

Wanna try it out super fast? Here’s the embed code to use the full Recursive variable font family from Google Fonts (but you will get a lot more flexibility & performance if you read further!)

<link href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap" rel="stylesheet">

Recursive is made for code, websites, apps, and more.
Recursive Mono has both Linear and Casual styles for different “voices” in code, along with cursive italics if you want them — plus a wider weight range for monospaced display typography.

Recursive Sans is proportional, but unlike most proportional fonts, letters maintain the same width across styles for more flexibility in UI interactions and layout.

I started Recursive as a thesis project for a type design masters program at KABK TypeMedia, and when I launched my type foundry, Arrow Type, I was subsequently commissioned by Google Fonts to finish and release Recursive as an open-source, OFL font.

You can see Recursive and learn more about it what it can do at recursive.design

Recursive is made to be a flexible type family for both websites and code, where its main purpose is to give developers and designers some fun & useful type to play with, combining fresh aesthetics with the latest in font tech.

First, a necessary definition: variable fonts are font files that fit a range of styles inside one file, usually in a way that allows the font user to select a style from a fluid range of styles. These stylistic ranges are called variable axes, and can be parameters, like font weight, font width, optical size, font slant, or more creative things. In the case of Recursive, you can control the “Monospacedness” (from Mono to Sans) and “Casualness” (between a normal, linear style and a brushy, casual style). Each type family may have one or more of its own axes and, like many features of type, variable axes are another design consideration for font designers.

You may have seen that Google Fonts has started adding variable fonts to its vast collection. You may have read about some of the awesome things variable fonts can do. But, you may not realize that many of the variable fonts coming to Google Fonts (including Recursive) have a lot more stylistic range than you can get from the default Google Fonts front end.

Because Google Fonts has a huge range of users — many of them new to web development — it is understandable that they’re keeping things simple by only showing the “weight” axis for variable fonts. But, for fonts like Recursive, this simplification actually leaves out a bunch of options. On the Recursive page, Google Fonts shows visitors eight styles, plus one axis. However, Recursive actually has 64 preset styles (also called named instances), and a total of five variable axes you can adjust (which account for a slew of more potential custom styles).

Recursive can be divided into what I think of as one of four “subfamilies.” The part shown by Google Fonts is the simplest, proportional (sans-serif) version. The four Recursive subfamilies each have a range of weights, plus Italics, and can be categorized as:

  • Sans Linear: A proportional, “normal”-looking sans-serif font. This is what gets shown on the Google Fonts website.
  • Sans Casual: A proportional “brush casual” font
  • Mono Linear: A monospace “normal” font
  • Mono Casual: A monospace “brush casual” font

This is probably better to visualize than to describe in words. Here are two tables (one for Sans, the other for Mono) showing the 64 named instances:

But again, the main Google Fonts interface only provides access to eight of those styles, plus the Weight axis:

Recursive has 64 preset styles — and many more using when using custom axis settings — but Google Fonts only shows eight of the preset styles, and just the Weight axis of the five available variable axes.

Not many variable fonts today have more than a Weight axis, so this is an understandable UX choice in some sense. Still, I hope they add a little more flexibility in the future. As a font designer & type fan, seeing the current weight-only approach feels more like an artificial flattening than true simplification — sort of like if Google Maps were to “simplify” maps by excluding every road that wasn’t a highway.

Luckily, you can still access the full potential of variable fonts hosted by Google Fonts: meet the Google Fonts CSS API, version 2. Let’s take a look at how you can use this to get more out of Recursive.

But first, it is helpful to know a few things about how variable fonts work.

How variable fonts work, and why it matters

If you’ve ever worked with photos on the web then you know that you should never serve a 9,000-pixel JPEG file when a smaller version will do. Usually, you can shrink a photo down using compression to save bandwidth when users download it.

There are similar considerations for font files. You can often reduce the size of a font dramatically by subsetting the characters included in it (a bit like cropping pixels to just leave the area you need). You can further compress the file by converting it into a WOFF2 file (which is a bit like running a raster image file though an optimizer tool like imageOptim). Vendors that host fonts, like Google Fonts, will often do these things for you automatically.

Now, think of a video file. If you only need to show the first 10 seconds of a 60-second video, you could trim the last 50 seconds to have a much small video file. 

Variable fonts are a bit like video files: they have one or more ranges of information (variable axes), and those can often either be trimmed down or “pinned” to a certain location, which helps to reduce file size. 

Of course, variable fonts are nothing like video files. Fonts record each letter shape in vectors, (similar to SVGs store shape information). Variable fonts have multiple “source locations” which are like keyframes in an animation. To go between styles, the control points that make up letters are mathematically interpolated between their different source locations (also called deltas). A font may have many sets of deltas (at least one per variable axis, but sometimes more). To trim a variable font, then, you must trim out unneeded deltas.

As a specific example, the Casual axis in Recursive takes letterforms from “Linear” to “Casual” by interpolating vector control points between two extremes: basically, a normal drawing and a brushy drawing. The ampersand glyph animation below shows the mechanics in action: control points draw rounded corners at one extreme and shift to squared corners on the other end.

Generally, each added axis doubles the number of drawings that must exist to make a variable font work. Sometimes the number is more or less – Recursive’s Weight axis requires 3 locations (tripling the number of drawings), while its Cursive axis doesn’t require extra locations at all, but actually just activates different alternate glyphs that already exist at each location. But, the general math is: if you only use opt into fewer axes of a variable font, you will usually get a smaller file.

When using the Google Fonts API, you are actually opting into each axis. This way, instead of starting with a big file and whittling it down, you get to pick and choose the parts you want.

Variable axis tags

If you’re going to use the Google Fonts API, you first need to know about font axes abbreviations so you can use them yourself.

Variable font axes have abbreviations in the form of four-letter “tags.” These are lowercase for industry-standard axes and uppercase for axes invented by individual type designers (also called “custom” or “private” axes). 

There are currently five standard axes a font can include: 

  • wght – Weight, to control lightness and boldness
  • wdth – Width, to control overall letter width
  • opsz – Optical Size, to control adjustments to design for better readability at various sizes
  • ital – Italic, generally to switch between separate upright/italic designs
  • slnt – Slant, generally to control upright-to-slanted designs with intermediate values available

Custom axes can be almost anything. Recursive includes three of them — Monospace (MONO), Casual (CASL), and Cursive (CRSV)  — plus two standard axes, wght and slnt.

Google Fonts API basics

When you configure a font embed from the Google Fonts interface, it gives you a bit of HTML or CSS which includes a URL, and this ultimately calls in a CSS document that includes one or more @font-face rules. This includes things like font names as well as links to font files on Google servers.

This URL is actually a way of calling the Google Fonts API, and has a lot more power than you might realize. It has a few basic parts: 

  1. The main URL, specifying the API (https://fonts.googleapis.com/css2)
  2. Details about the fonts you are requesting in one or more family parameters
  3. A font-display property setting in a display parameter

As an example, let’s say we want the regular weight of Recursive (in the Sans Linear subfamily). Here’s the URL we would use with our CSS @import:

@import url('https://fonts.googleapis.com/css2?family=Recursive&display=swap');

Or we can link it up in the <head> of our HTML:

<link href="https://fonts.googleapis.com/css2?family=Recursive&display=swap" rel="stylesheet">

Once that’s in place, we can start applying the font in CSS:

body {   font-family: 'Recursive', sans-serif; }

There is a default value for each axis:

  • MONO 0 (Sans/proportional)
  • CASL 0 (Linear/normal)
  • wght 400 (Regular)
  • slnt 0 (upright)
  • CRSV 0 (Roman/non-cursive lowercase)

Choose your adventure: styles or axes

The Google Fonts API gives you two ways to request portions of variable fonts:

  1. Listing axes and the specific non-default values you want from them
  2. listing axes and the ranges you want from them

Getting specific font styles

Font styles are requested by adding parameters to the Google Fonts URL. To keep the defaults on all axes but use get a Casual style, you could make the query Recursive:CASL@1 (this will serve Recursive Sans Casual Regular). To make that Recursive Mono Casual Regular, specify two axes before the @ and then their respective values (but remember, custom axes have uppercase tags):


To request both Regular and Bold, you would simply update the family call to Recursive:wght@400;700, adding the wght axis and specific values on it:


A very helpful thing about Google Fonts is that you can request a bunch of individual styles from the API, and wherever possible, it will actually serve variable fonts that cover all of those requested styles, rather than separate font files for each style. This is true even when you are requesting specific locations, rather than variable axis ranges — if they can serve a smaller font file for your API request, they probably will.

As variable fonts can be trimmed more flexibility and efficiently in the future, the files served for given API requests will likely get smarter over time. So, for production sites, it may be best to request exactly the styles you need.

Where it gets interesting, however, is that you can also request variable axes. That allows you to retain a lot of design flexibility without changing your font requests every time you want to use a new style.

Getting a full variable font with the Google Fonts API

The Google Fonts API seeks to make fonts smaller by having users opt into only the styles and axes they want. But, to get the full benefits of variable fonts (more design flexibility in fewer files), you should use one or more axes. So, instead of requesting single styles with Recursive:wght@400;700, you can instead request that full range with Recursive:wght@400..700 (changing from the ; to .. to indicate a range), or even extending to the full Recursive weight range with Recursive:wght@300..1000 (which adds very little file size, but a whole lot of extra design oomph).

You can add additional axes by listing them alphabetically (with lowercase standard axes first, then uppercase custom axes) before the @, then specifying their values or ranges after that in the same order. For instance, to add the MONO axis and the wght axis, you could use Recursive:wght,MONO@300..1000,0..1 as the font query.

Or, to get the full variable font, you could use the following URL:


Of course, you still need to put that into an HTML link, like this:

<link href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap" rel="stylesheet">

Customizing it further to balance flexibility and filesize 

While it can be tempting to use every single axis of a variable font, it’s worth remembering that each additional axis adds to the overall files ize. So, if you really don’t expect to use an axis, it makes sense to leave it off. You can always add it later.

Let’s say you want Recursive’s Mono Casual styles in a range of weights,. You could use Recursive:wght,CASL,MONO@300..1000,1,1 like this:

<link href="https://fonts.googleapis.com/css2?family=Recursive:CASL,MONO,wght@1,1,300..1000&display=swap" rel="stylesheet">

You can, of course, add multiple font families to an API call with additional family parameters. Just be sure that the fonts are alphabetized by family name.

<link href="https://fonts.googleapis.com/css2?family=Inter:slnt,wght@-10..0,100..900?family=Recursive:CASL,MONO,wght@1,1,300..1000&display=swap" rel="stylesheet">

Using variable fonts

The standard axes can all be controlled with existing CSS properties. For instance, if you have a variable font with a weight range, you can specify a specific weight with font-weight: 425;. A specific Slant can be requested with font-style: oblique 9deg;. All axes can be controlled with font-variation-settings. So, if you want a Mono Casual very-heavy style of Recursive (assuming you have called the full family as shown above), you could use the following CSS:

body {  font-weight: 950;  font-variation-settings: 'MONO' 1, 'CASL' 1; }

Something good to know: font-variation-settings is much nicer to use along with CSS custom properties

Another useful thing to know is that, while you should be able to activate slant with font-style: italic; or font-style: oblique Xdeg;, browser support for this is inconsistent (at least at the time of this writing), so it is useful to utilize font-variation-settings for the Slant axis, as well.

You can read more specifics about designing with variable fonts at VariableFonts.io and in the excellent collection of CSS-Tricks articles on variable fonts.

Nerdy notes on the performance of variable fonts

If you were to using all 64 preset styles of Recursive as separate WOFF2 files (with their full, non-subset character set), it would be total of about 6.4 MB. By contrast, you could have that much stylistic range (and everything in between) at just 537 KB. Of course, that is a slightly absurd comparison — you would almost never actually use 64 styles on a single web page, especially not with their full character sets (and if you do, you should use subsets and unicode-range).

A better comparison is Recursive with one axis range versus styles within that axis range. In my testing, a Recursive WOFF2 file that’s subset to the “Google Fonts Latin Basic” character set (including only characters to cover English and western European languages), including the full 300–1000 Weight range (and all other axes “pinned” to their default values) is 60 KB. Meanwhile, a single style with the same subset is 25 KB. So, if you use just three weights of Recursive, you can save about 15 KB by using the variable font instead of individual files.

The full variable font as a subset WOFF2 clocks in at 281 KB which is kind of a lot for a font, but not so much if you compare it to the weight of a big JPEG image. So, if you assume that individual styles are about 25 KB, if you plan to use more than 11 styles, you would be better off using the variable font.

This kind of math is mostly an academic exercise for two big reasons:

  1. Variable fonts aren’t just about file size.The much bigger advantage is that they allow you to just design, picking the exact font weights (or other styles) that you want. Is a font looking a little too light? Bump up the font-weight a bit, say from 400 to 425!
  2. More importantly (as explained earlier), if you request variable font styles or axes from Google Fonts, they take care of the heavy lifting for you, sending whatever fonts they deem the most performant and useful based on your API request and the browsers visitors access your site from.

So, you don’t really need to go downloading fonts that the Google Fonts API returns to compare their file sizes. Still, it’s worth understanding the general tradeoffs so you can best decide when to opt into the variable axes and when to limit yourself to a couple of styles.

What’s next?

Fire up CodePen and give the API a try! For CodePen, you will probably want to use the CSS @import syntax, like this in the CSS panel:

@import url('https://fonts.googleapis.com/css2?family=Recursive:CASL,CRSV,MONO,slnt,wght@0..1,0..1,0..1,-15..0,300..1000&display=swap');

It is apparently better to use the HTML link syntax to avoid blocking parallel downloads of other resources. In CodePen, you’d crack open the Pen settings, select HTML, then drop the <link> in the HTML head settings.

Or, hey, you can just fork my CodePen and experiment there:

Take an API configuration shortcut

If you are want to skip the complexity of figuring out exact API calls and looking to opt into variable axes of Recursive and make semi-advanced API calls, I have put together a simple configuration tool on the Recursive minisite (click the “Get Recursive” button). This allows you to quickly select pinned styles or variable ranges that you want to use, and even gives estimates for the resulting file size. But, this only exposes some of the API’s functionality, and you can get more specific if you want. It’s my attempt to get people using the most stylistic range in the smallest files, taking into account the current limitations of variable font instancing.

Use Recursive for code

Also, Recursive is actually designed first as a font to use for code. You can use it on CodePen via your account settings. Better yet, you can download and use the latest Recursive release from GitHub and set it up in any code editor.

Explore more fonts!

The Google Fonts API doc helpfully includes a (partial) list of variable fonts along with details on their available axis ranges. Some of my favorites with axes beyond just Weight are Crimson Pro (ital, wght), Work Sans (ital, wght), Encode Sans (wdth, wght), and Inter (slnt, wght). You can also filter Google Fonts to show only variable fonts, though most of these results have only a Weight axis (still cool and useful, but don’t need custom URL configuration).

Some more amazing variable fonts are coming to Google Fonts. Some that I am especially looking forward to are:

  • Fraunces: “A display, “Old Style” soft-serif typeface inspired by the mannerisms of early 20th century typefaces such as Windsor, Souvenir, and the Cooper Series”
  • Roboto Flex: Like Roboto, but withan extensive ranges of Weight, Width, and Optical Size
  • Crispy: A creative, angular, super-flexible variable display font
  • Science Gothic: A squarish sans “based closely on Bank Gothic, a typeface from the early 1930s—but a lowercase, design axes, and language coverage have been added”

And yes, you can absolutely download and self-host these fonts if you want to use them on projects today. But stay tuned to Google Fonts for more awesomely-flexible typefaces to come!

Of course, the world of type is much bigger than open-source fonts. There are a bunch of incredible type foundries working on exciting, boundary-pushing fonts, and many of them are also exploring new & exciting territory in variable fonts. Many tend to take other approaches to licensing, but for the right project, a good typeface can be an extremely good value (I’m obviously biased, but for a simple argument, just look at how much typography strengthens brands like Apple, Stripe, Google, IBM, Figma, Slack, and so many more). If you want to feast your eyes on more possibilities and you don’t already know these names, definitely check out DJR, OHno, Grilli, XYZ, Dinamo, Typotheque, Underware, Bold Monday, and the many very-fun WIP projects on Future Fonts. (I’ve left out a bunch of other amazing foundries, but each of these has done stuff I particularly love, and this isn’t a directory of type foundries.)

Finally, some shameless plugs for myself: if you’d like to support me and my work beyond Recursive, please consider checking out my WIP versatile sans-serif Name Sans, signing up for my (very) infrequent newsletter, and giving me a follow on Instagram.

The post Getting the Most Out of Variable Fonts on Google Fonts appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , ,

The Fastest Google Fonts

When you use font-display: swap;, which Google Fonts does when you use the default &display=swap part of the URL , you’re already saying, “I’m cool with FOUT,” which is another way of saying web text is displayed right away, and when the web font is ready, “swap” to it.

There is already an async nature to what you are doing, so you might as well extend that async-ness to the rest of the font loading. Harry Roberts:

If you’re going to use font-display for your Google Fonts then it makes sense to asynchronously load the whole request chain.

Harry’s recommended snippet:

<link rel="preconnect"       href="https://fonts.gstatic.com"       crossorigin />  <link rel="preload"       as="style"       href="$  CSS&display=swap" />  <link rel="stylesheet"       href="$  CSS&display=swap"       media="print" onload="this.media='all'" />

$ CSS is the main part of the URL that Google Fonts gives you.

Looks like a ~20% render time savings with no change in how it looks/feels when loading/. Other than that, it’s faster.

Direct Link to ArticlePermalink

The post The Fastest Google Fonts appeared first on CSS-Tricks.


, ,

How to Redirect a Search Form to a Site-Scoped Google Search

This is just a tiny little trick that might be helpful on a site where you don’t have the time or desire to build out a really good on-site search solution. Google.com itself can perform searches scoped to one particular site. The trick is getting people there using that special syntax without them even knowing it.

Make a search form:

<form action="https://google.com/search" target="_blank" type="GET">    <label>      Search CSS-Tricks on Google:       <input type="search" name="q">    </label>    <input type="submit" value="Go">  </form>

When that form is submitted, we’ll intercept it and change the value to include the special syntax:

var form = document.querySelector("form");  form.addEventListener("submit", function (e) {   e.preventDefault();   var search = form.querySelector("input[type=search]");   search.value = "site:css-tricks.com " + search.value;   form.submit(); }); 

That’s all.

The post How to Redirect a Search Form to a Site-Scoped Google Search appeared first on CSS-Tricks.


, , , ,