Making Calendars With Accessibility and Internationalization in Mind

But let’s take this one more step

You might recall how all the days are just one <ol> with list items. To style these into a readable calendar, we dive into the wonderful world of CSS Grid. In fact, we can repurpose the same grid from a starter calendar template right here on CSS-Tricks, but updated a smidge with the :is() relational pseudo to optimize the code.

I figured we could do something similar without changing anything in the HTML or JavaScript. I’ve taken the liberty to include full names for months, and numbers instead of day names, to make it more readable. Enjoy!

January 2023 calendar grid.

What should we call our calendar component? In my native language, it would be called “kalender element”, so let’s use that and shorten that to “Kal-El” — also known as Superman’s name on the planet Krypton.

// en-GB
{
firstDay: 1,
weekend: [6, 7],
minimalDays: 4
}

// ms-BN
{
firstDay: 7,
weekend: [5, 7],
minimalDays: 1
}

[data-firstday="1"] [data-day="3"]:first-child {
--kalel-li-gc: 1 / 4;
}

I don’t want to re-create the wheel here, but I will show you how we can get a dang good calendar with vanilla JavaScript. We’ll look into accessibility, using semantic markup and screenreader-friendly <time> -tags — as well as internationalization and formatting, using the Intl.Locale, Intl.DateTimeFormat and Intl.NumberFormat-APIs.

Initial data and internationalization

<ol>
<li><abbr title="Sunday">Sun</abbr></li>
<li><abbr title="Monday">Mon</abbr></li>
<!-- etc. -->
</ol>

Many developers fear the Date() object and stick with older libraries like moment.js. But while there are many “gotchas” when it comes to dates and formatting, JavaScript has a lot of cool APIs and stuff to help out!

jor-el {
background: var(--jorel-bg, none);
display: var(--jorel-d, grid);
gap: var(--jorel-gap, 2.5rem);
grid-template-columns: var(--jorel-gtc, repeat(auto-fill, minmax(320px, 1fr)));
padding: var(--jorel-p, 0);
}

Final demo

Bonus: Confetti Calendar

And that’s it! Thanks to the Intl.Locale, Intl.DateTimeFormat and Intl.NumberFormat APIs, we can now simply change the lang-attribute of the <html> element to change the context of the calendar based on the current region:

<time datetime="2023-01">January <i>2023</i></time>

Day names

[data-today] {
--kalel-day-bdrs: 50%;
--kalel-day-bg: hsl(0, 86%, 40%);
--kalel-day-hover-bgc: hsl(0, 86%, 70%);
--kalel-day-c: #fff;
}

<ol>
${weekdays(config.info.firstDay,locale).map(name => `
<li>
<abbr title="${name.long}">${name.short}</abbr>
</li>`).join('')
}
</ol>

Day numbers

To “pad” the numbers in the datetime attribute, we use a little helper method:

const date = config.date ? new Date(config.date) : today;

kal-el :is(ol, ul) li {
border-color: var(--kalel-li-bdc, hsl(0, 0%, 80%));
border-style: var(--kalel-li-bds, solid);
border-width: var(--kalel-li-bdw, 0 0 1px 0);
grid-column: var(--kalel-li-gc, initial);
text-align: var(--kalel-li-tal, end);
}

Before we had the Intl.Locale API and its weekInfo method, it was pretty hard to create an international calendar without many **objects and arrays with information about each locale or region. Nowadays, it’s easy-peasy. If we pass in en-GB, the method returns:

There are 52 weeks in a year, sometimes 53. While it’s not super common, it can be nice to display the number for a given week in the calendar for additional context. I like having it now, even if I don’t wind up not using it. But we’ll totally use it in this tutorial.

Semantic markup

Notice that I’m defining configurable CSS variables along the way (and prefixing them with ---kalel- to avoid conflicts).

Calendar wrapper

<kal-el data-firstday="${ config.info.firstDay }">

[...Array(12).keys()].map(i =>
render(
new Date(date.getFullYear(),
i,
date.getDate()),
config.locale,
date.getMonth()
)
).join('')

[data-weekend]:not([data-today]) {
--kalel-day-c: var(--kalel-weekend-c, hsl(0, 86%, 46%));
}

const pad = (val) => (val + 1).toString().padStart(2, '0');

Week number

function getWeek(cur) {
const date = new Date(cur.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
const week = new Date(date.getFullYear(), 0, 4);
return 1 + Math.round(((date.getTime() - week.getTime()) / 86400000 - 3 + (week.getDay() + 6) % 7) / 7);
}

Each date in the calendar grid gets a number. Each number is a list item (<li>) in an ordered list (<ol>), and the inline <time> tag wraps the actual number.

Day numbers

Maybe you need to display a full year of dates! So, rather than render the current month, you might want to display all of the month grids for the current year.

The <time> element is going to be a big one for us because it helps translate dates into a format that screenreaders and search engines can parse more accurately and consistently. For example, here’s how we can convey “January 2023” in our markup:

Week numbers

When we call the month, we can set whether we want to use the long name (e.g. February) or the short name (e.g. Feb.). Let’s use the long name since it’s the title above the calendar:

<li>
<time datetime="2023-01-01">1</time>
</li>

const config = Object.assign(
{
locale: (document.documentElement.getAttribute('lang') || 'en-US'),
today: {
day: today.getDate(),
month: today.getMonth(),
year: today.getFullYear()
}
}, settings
);

[data-weeknumber]::before {
display: var(--kalel-weeknumber-d, inline-block);
content: attr(data-weeknumber);
position: absolute;
inset-inline-start: 0;
/* additional styles */
}

Let’s add a little style to the “current” date, so it stands out. These are just my styles. You can totally do what you’d like here.

Well, the nice thing about the approach we’re using is that we can call the render method as many times as we want and merely change the integer that identifies the month on each instance. Let’s call it 12 times based on the current year.

One of the common things a typical online calendar does is highlight the current date. So let’s create a reference for that:

We’ll use a data-weeknumber attribute as a styling hook and include it in the markup for each date that is the week’s first date.

Intl.DateTimeFormat([locale], { weekday: 'long' })
Intl.DateTimeFormat([locale], { weekday: 'short' })

We also need to determine which month to initially display when the calendar is rendered. That’s why we extended the config object with the primary date. This way, if no date is provided in the settings object, we’ll use the today reference instead:

  1. We create a “dummy” array, based on the “number of days” variable, which we’ll use to iterate.
  2. We create a day variable for the current day in the iteration.
  3. We fix the discrepancy between the Intl.Locale API and getDay().
  4. If the day is equal to today, we add a data-* attribute.
  5. Finally, we return the <li> element as a string with merged data.
  6. tabindex="0" makes the element focusable, when using keyboard navigation, after any positive tabindex values (Note: you should never add positive tabindex-values)

const weekdays = (firstDay, locale) => {
const date = new Date(0);
const arr = [...Array(7).keys()].map(i => {
date.setDate(5 + i)
return {
long: new Intl.DateTimeFormat([locale], { weekday: 'long'}).format(date),
short: new Intl.DateTimeFormat([locale], { weekday: 'short'}).format(date)
}
})
for (let i = 0; i < 8 - firstDay; i++) arr.splice(0, 0, arr.pop());
return arr;
}

There are many considerations when building a calendar component — far more than what is covered in the articles I linked up. If you think about it, calendars are fraught with nuance, from handling timezones and date formats to localization and even making sure dates flow from one month to the next… and that’s before we even get into accessibility and additional layout considerations depending on where the calendar is displayed and whatnot.

Doing a quick search here on CSS-Tricks shows just how many different ways there are to approach calendars. Some show how CSS Grid can create the layout efficiently. Some attempt to bring actual data into the mix. Some rely on a framework to help with state management.

Next, we’ll create a render method within our kalEl-method:

January 2023 calendar grid.
de-DE
January 2023 calendar grid.
fa-IR
January 2023 calendar grid.
zh-Hans-CN-u-nu-hanidec

Styling the calendar

const month = date.getMonth();
const year = date.getFullYear();
const numOfDays = new Date(year, month + 1, 0).getDate();
const renderToday = (year === config.today.year) && (month === config.today.month);

@media all and (max-width: 800px) {
li abbr::after {
content: attr(title);
}
}

<time datetime="${year}-${(pad(month))}">
${new Intl.DateTimeFormat(
locale,
{ month:'long'}).format(date)} <i>${year}</i>
</time>

Weekday names

as simple as calling the render-method 12 times, and just change the integer for monthi:

Showing the first day of the month falling on a Thursday.

function kalEl(settings = {}) { ... }

kal-el :is(ol, ul) {
display: grid;
font-size: var(--kalel-fz, small);
grid-row-gap: var(--kalel-row-gap, .33em);
grid-template-columns: var(--kalel-gtc, repeat(7, 1fr));
list-style: none;
margin: unset;
padding: unset;
position: relative;
}

Seven-column calendar grid with grid lines shown.

Let’s create a function to get things going:

<ol>
<li>
<abbr title="S">Sunday</abbr>
</li>
</ol>

In other words, we’re making a calendar… only without the extra dependencies you might typically see used in a tutorial like this, and with some of the nuances you might not typically see. And, in the process, I hope you’ll gain a new appreciation for newer things that JavaScript can do while getting an idea of the sorts of things that cross my mind when I’m putting something like this together.

if (!config.info) config.info = new Intl.Locale(config.locale).weekInfo || {
firstDay: 7,
weekend: [6, 7]
};

<li data-day="7" data-weeknumber="1" data-weekend="">
<time datetime="2023-01-08">8</time>
</li>

Rendering

<jor-el id="app" data-year="true">
<kal-el data-firstday="7">
<!-- etc. -->
</kal-el>

<!-- other months -->
</jor-el>

const render = (date, locale) => { ... }

Remember all the extra data-* attributes we defined when writing our markup? We can hook into those to update which grid column (--kalel-li-gc) the first date number of the month is placed on:

${[...Array(numOfDays).keys()].map(i => {
const cur = new Date(year, month, i + 1);
let day = cur.getDay(); if (day === 0) day = 7;
const today = renderToday && (config.today.day === i + 1) ? ' data-today':'';
return `
<li data-day="${day}"${today}${i === 0 || day === config.info.firstDay ? ` data-weeknumber="${new Intl.NumberFormat(locale).format(getWeek(cur))}"`:''}${config.info.weekend.includes(day) ? ` data-weekend`:''}>
<time datetime="${year}-${(pad(month))}-${pad(i)}" tabindex="0">
${new Intl.NumberFormat(locale).format(i + 1)}
</time>
</li>`
}).join('')}

We’re going to get deeper in rendering in just a moment. But first, I want to make sure that the details we set up have semantic HTML tags associated with them. Setting that up right out of the box gives us accessibility benefits from the start.

const today = new Date();

First, we have the non-semantic wrapper: <kal-el>. That’s fine because there isn’t a semantic <calendar> tag or anything like that. If we weren’t making a custom element, <article> might be the most appropriate element since the calendar could stand on its own page.

Similar Posts