Localized Calendar Component with Svelte and TailwindCSS
Contents
Introduction
Recently, I needed to create a localized calendar view for a website and decided to craft it from scratch, instead of relying on a pre-made component. I often prefer this approach when developing with Svelte because it reduces the number of dependencies of the project and allows to really understand how things work under the hood. This in turns makes maintenance or customization easier and it’s also fun!
In recent years/months, browser support for internationalization in JavaScript has reached an “okay” level, and it was surprisingly easy to achieve a satisfactory result that works for most locales without relying on an external library (except for a polyfill).
HTML Markup
We want to display our monthly calendar view as a table, like you’re probably used to seeing. Each column will represent a day, and each row will represent a week. Since we want our view to update according to the locale’s preferences, the first column will sometimes represent Monday, sometimes Sunday or Saturday, sometimes even Friday.
Since html tables are painful to work with and stylize, I opted for a collection of <div>
elements arranged with CSS into a grid thanks to the display: grid
property.
The base of our layout will be the following:
<div>
<!-- title and navigation -->
<div class="flex flex-nowrap items-center text-center">
<div>
<button class="btn" aria-label="See previous month">
< <!-- left chevron -->
</button>
</div>
<h2 class="grow whitespace-nowrap">
<!--
this will be formatted according to the
locale date formatting and translated
-->
Month 20xx
</h2>
<div>
<button class="btn" aria-label="See next month">
> <!-- right chevron -->
</button>
</div>
</div>
<!-- header row -->
<div class="grid grid-cols-7 justify-items-center">
<!--
first item might sometimes be another day
and will be translated
-->
<div>Monday</div>
<div>Tuesday</div>
<!-- ... -->
</div>
<!-- days table -->
<div class="grid grid-cols-7 justify-items-center">
<!--
the number in the class name below will change
for each month and depending on the locale
-->
<div class="first:col-start-1">1</div>
<div class="first:col-start-1">2</div>
<!-- ... -->
</div>
</div>
The first thing to note is that we use the .grid .grid-cols-7
classes for the heading row (with the day names) and
the main table-like <div>
. This creates a grid with 7 columns as you might expect (gotta love Tailwind for this).
The .justify-items-center
ensures that each child <div>
is horizontally centered inside of its grid cell.
Finally and most importantly, not each month will have its first day on a Monday (or whichever the first column day is
according to the locale). As such, we have to skip a few cells to move the first day <div>
into the right column. This
is achieved with the .col-start-*
classes and
should only be applied to the first day element. It will automatically offset the next elements along with it, and all
elements will wrap according to our preference of 7 columns.
Since we want to generate those elements dynamically using JavaScript, it’s easier if we apply the same classes to each
day element (to avoid conditionals), so we prepend our class with first:
, which will only apply to the element if
it’s the first child of its parent.
Generating the Localized Data
Ok, we have a markup template, we now need to populate it with localized data. For this, we need to have the following, knowing the currently displayed month and year:
- the month title (with year),
- which day is the first day of the week for the locale,
- the list of the day names, starting with the first day of the week for the locale,
- the last day of the month, or how many days are in the displayed month,
- and in which column should the first day of the displayed month land.
There is a bit of math below, but I promise it’s not going to be too complicated! I’m going to do my best to explain how it works.
Month Title
This is probably the easiest bit, and we can retrieve the formatted month and year title with the following code,
relying on the Date.toLocaleString()
method:
const monthTitle = $derived(
new Date(year, month, 1).toLocaleString(locale, {
month: 'long', year: 'numeric'
})
)
locale
, year
and month
are reactive pieces of state in Svelte,
so we derive a new reactive variable with the $derived
rune. The derived
value will reactively update any time any of its dependencies change.First Day of the Week
JavaScript provides an API to retrieve information about the week structure for a locale via the Intl.Locale.getWeekInfo()
method. Browser support is not exactly great for this API (some browsers expose it via a weekInfo
property instead,
some browsers don’t have it at all), so we use the following polyfill to level the playing field: @formatjs/intl-locale
.
According to the API docs, this method returns an object with a firstDay
key as an integer, where 1
is Monday and 7
is Sunday:
const firstDayOfWeek = $derived(
new Intl.Locale(locale).getWeekInfo().firstDay
)
List of Day Names
To get the list of day names, we will similarly use the i18n capabilities of JavaScript, this time with a twist. Since
we are localizing multiple dates (one for each day of the week) with the same formatting, we can optimize our code by
first constructing a Intl.DateTimeFormat
object and re-using it to format multiple dates. This speeds up execution as the heavy data for the locale is only
loaded once instead of once per call.
const dateTimeFormat = $derived(
new Intl.DateTimeFormat(locale, { weekday: 'short' })
)
const dayNames = $derived(
Array.from(
{ length: 7 },
(_, i) => dateTimeFormat.format(
new Date(2018, 0, i + firstDayOfWeek)
)
)
)
The list must start with the correct day according to firstDayOfWeek
defined above. We leverage the fact that year
2018 started on a Monday to generate the list of days starting with the correct day. With our index i
starting at
zero, we simply add our firstDayOfWeek
to retrieve the the date which corresponds to the day name. Note that Date()
indexes months starting at zero, so month 0
is January (for some reason…).
Last Day of the Month
To get the number of days in the displayed month, we use another JavaScript trick. The Date()
object will not complain
if we give it a day or month index that is invalid (e.g. 32
for the day number), and will instead wrap as necessary to
land on a valid date. As such, Date(2018, 0, 32)
gives Thu Feb 01 2018
. Likewise, we can retrieve the last
day of January with Date(2018, 1, 0)
(remember that days start at 1 normally), which gives Wed Jan 31 2018
.
If we now were to try Date(2018, 12, 1)
, we would get Tue Jan 01 2019
(remember that months start at 0).
Armed with this knowledge, we can now find the last day in month
(1-indexed):
const lastDay = $derived(
new Date(year, month + 1, 0).getDate()
)
Column Offset for the First Day
In the markup section above, we determined that we need to offset the first item in the table by a number of rows so that it ends up in the correct column. This involves a bit of simple math and can be achieved like so:
const firstDayColumn = $derived(
(
(
new Date(year, month, 1).getDay() + 7 - firstDayOfWeek
) % 7
) + 1
)
First, we construct a date object for the first day of the displayed month, then we retrieve the corresponding day of
the week with getDay()
. This returns 0
for Sunday, 1
for Monday, etc.
Let’s imagine we are in August 2024, and so the values we get from getDay()
is 4, as the month starts on a Thursday.
Let’s also imagine for the example that we are dealing with the fr
locale which gave us firstDayOfWeek = 1
.
By subtracting our value firstDayOfWeek
, for our example, we end up with 4 - 1 = 3
. So, we need to offset the day
cell by 3 columns to the right. Brilliant!
Now we need to adjust a couple of things after the subtraction, because if getDay()
returns a number smaller than our firstDayOfWeek
, we will end up with a negative number. To avoid this, we add 7
to the result, and then apply a
modulo 7
to the result to end up in the 0-6
range.
Since the .col-start-*
class is not an offset but a starting index, we finally add 1
to the result of the
calculation to get the column index (starting at 1).
Svelte Component
Phew, now that the maths are out of the way, let’s see how we can construct our component with the pieces described until now.
I want the component to expose a prop
named locale
which the consumer can use to define the locale string to use.
The default value will be en
:
interface Props {
locale?: string
}
let { locale = 'en' }: Props = $props()
Then, I need two state variables to store the year and month, and we will initialize them with the current date:
let year = $state(new Date().getFullYear())
let month = $state(new Date().getMonth())
It could be interesting to also expose those as props and would be easy enough to do, but it’s not required for this demo.
To generate the list of all day cells, we also need such a list that contains all numbers from 1 to the lastDay
that
we calculated previously. We create a helper range
function:
// Helper method to generate a range of integers from `start` to `end`, inclusive
const range = (start: number, end: number) => {
return Array.from({ length: end - start + 1 }, (_, i) => i + start)
}
We should also be able to increment and decrement the month and year counters. Knowing how the Date()
object works, we
know it’s a simple as:
<button class="btn" onclick={() => month--} aria-label="See previous month">
<
</button>
<!-- ... -->
<button class="btn" onclick={() => month++} aria-label="See next month">
<
</button>
The overflow to the next or previous year will be handled properly. We could also do something like below, which would
be less “hacky” and would be necessary if we relied on the year
and month
variables for anything else than the input
to the Date
constructor:
const prevMonth = () => {
month--
if (month < 0) {
month = 11
year--
}
}
const nextMonth = () => {
month++
if (month > 11) {
month = 0
year++
}
}
The title is simply:
<h2 class="grow whitespace-nowrap">
{monthTitle}
</h2>
The row with the day names is constructed as follows:
<div class="grid grid-cols-7 justify-items-center">
{#each dayNames as dayName}
<div>{dayName}</div>
{/each}
</div>
And the table is generated with:
<div class="grid grid-cols-7 justify-items-center">
{#each range(1, lastDay) as day}
<div class={`first:col-start-${firstDayColumn}`}>
{day}
</div>
{/each}
</div>
Since we dynamically generate the class name for the column offset, the Tailwind compiler doesn’t pick them up and will
tree-shake them out of your CSS (depending on your config). In order to ensure they stay present in the final bundle,
the tailwind.config.js
file can be edited to include the safelist
key:
export default {
safelist: [{ pattern: /^col-start-/, variants: ['first'] }]
}
The Result
Here’s the final result after implementing all the things discussed in this article. You can play with it below!
The full component source code is available on GitHub.
November 2024
Conclusion
Throughout this article, we’ve seen how we can leverage modern web APIs to create a localized Svelte component which serves content tailored to the user’s preferences. We’ve also derived a couple of maths formulae to produce the correct markup for displaying a monthly calendar view.
I hope you found this article useful and could learn a thing or two about using JavaScript APIs for the localization of your front-end applications.
‘Till next time!