You likely already have your own component library with inputs, buttons, popovers, and more. And you probably have your own design tokens too. So rather than re-implement all those parts, Cally's components are designed around composition and ease of integration.
In this guide we will use Shoelace as an example. But the principles apply to any other component library. We will not cover the finer details of Shoelace, but rather the generic high-level concepts.
What makes a date picker?
A date picker typically consists of:
- An input - for manual date entry
- A button - to toggle the popout
- A popout - to contain the calendar
- A calendar - for browsing and selecting dates
Shoelace provides everything but the calendar. Using these lower-level pieces, we can compose together a more complex component. Additionally, shoelace provides a number of tokens for styling, which we can use to match its look and feel.
Calendar components
Let's start by styling the calendar components. We'll use Shoelace's card and icon components, plus some tokens.
<style>
.flex {
display: flex;
}
.gap-l {
gap: var(--sl-spacing-large);
}
.flex-wrap {
flex-wrap: wrap;
}
.justify-center {
justify-content: center;
}
calendar-range {
&::part(button) {
border-radius: var(--sl-input-border-radius-small);
background-color: var(--sl-input-background-color);
border: 1px solid var(--sl-input-border-color);
padding: var(--sl-spacing-x-small);
}
}
calendar-month {
--color-accent: var(--sl-color-primary-600);
&::part(button) {
border-radius: var(--sl-input-border-radius-medium);
}
&::part(range-inner) {
border-radius: 0;
}
&::part(range-start) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&::part(range-end) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
&::part(range-start range-end) {
border-radius: var(--sl-input-border-radius-medium);
}
}
</style>
<sl-card>
<calendar-range months="2">
<sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
<sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
<div class="flex gap-l flex-wrap justify-center">
<calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
</div>
</calendar-range>
</sl-card>
This is already looking good. Now we need to integrate with the rest of the components.
For more information on styling and theming the calendar components please see the full theming guide.
The input and toggle button
Let's use Shoelace's input, button, and icon components to build out the rest of the date picker.
<style>
.flex {
display: flex;
}
.gap-xs {
gap: var(--sl-spacing-x-small);
}
.gap-l {
gap: var(--sl-spacing-large);
}
.flex-wrap {
flex-wrap: wrap;
}
.justify-center {
justify-content: center;
}
.flex-1 {
flex: 1;
}
.align-end {
align-items: end;
}
calendar-range {
&::part(button) {
border-radius: var(--sl-input-border-radius-small);
background-color: var(--sl-input-background-color);
border: 1px solid var(--sl-input-border-color);
padding: var(--sl-spacing-x-small);
}
}
calendar-month {
--color-accent: var(--sl-color-primary-600);
&::part(button) {
border-radius: var(--sl-input-border-radius-medium);
}
&::part(range-inner) {
border-radius: 0;
}
&::part(range-start) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&::part(range-end) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
&::part(range-start range-end) {
border-radius: var(--sl-input-border-radius-medium);
}
}
</style>
<div class="flex gap-xs align-end">
<sl-input label="Date range" class="flex-1"></sl-input>
<sl-button> <sl-icon name="calendar-range" label="Toggle calendar"></sl-icon> </sl-button>
</div>
<sl-card>
<calendar-range months="2">
<sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
<sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
<div class="flex gap-l flex-wrap justify-center">
<calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
</div>
</calendar-range>
</sl-card>
This is now starting to look like an actual date picker, but we need to show and hide the calendar on click.
Adding the popup
Shoelace provides a popup component that we can use to show and hide the calendar. The popup component is also responsible for positioning its content alongside some anchor element.
We'll place the card we've already built inside the popup. However, nothing
will happen until we wire up the button to the popup's active
state.
<style>
.flex {
display: flex;
}
.gap-xs {
gap: var(--sl-spacing-x-small);
}
.gap-l {
gap: var(--sl-spacing-large);
}
.flex-wrap {
flex-wrap: wrap;
}
.justify-center {
justify-content: center;
}
.flex-1 {
flex: 1;
}
.align-end {
align-items: end;
}
calendar-range {
&::part(button) {
border-radius: var(--sl-input-border-radius-small);
background-color: var(--sl-input-background-color);
border: 1px solid var(--sl-input-border-color);
padding: var(--sl-spacing-x-small);
}
}
calendar-month {
--color-accent: var(--sl-color-primary-600);
&::part(button) {
border-radius: var(--sl-input-border-radius-medium);
}
&::part(range-inner) {
border-radius: 0;
}
&::part(range-start) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&::part(range-end) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
&::part(range-start range-end) {
border-radius: var(--sl-input-border-radius-medium);
}
}
</style>
<div class="flex gap-xs align-end">
<sl-input label="Date range" class="flex-1"></sl-input>
<sl-button> <sl-icon name="calendar-range" label="Toggle calendar"></sl-icon> </sl-button>
</div>
<sl-popup placement="bottom-end" distance="8" auto-size="horizontal">
<sl-card>
<calendar-range months="2">
<sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
<sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
<div class="flex gap-l flex-wrap justify-center">
<calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
</div>
</calendar-range>
</sl-card>
</sl-popup>
Adding interactivity
All the HTML and CSS is now in place, but nothing actually works. With a little javascript to wire up events, we can finish up the component and have a functional date range picker.
The <calendar-range>
and
<calendar-date>
components both emit change
events
on selection. We can use this to set the value
of the input and
close the popup. Additionally, we can listen to the input
event
on the input to set the value
of the calendar.
<style>
.flex {
display: flex;
}
.gap-xs {
gap: var(--sl-spacing-x-small);
}
.gap-l {
gap: var(--sl-spacing-large);
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-1 {
flex: 1;
}
.align-end {
align-items: end;
}
.justify-center {
justify-content: center;
}
sl-card {
max-inline-size: calc(var(--auto-size-available-width) - 20px);
}
calendar-range {
&::part(button) {
border-radius: var(--sl-input-border-radius-small);
background-color: var(--sl-input-background-color);
border: 1px solid var(--sl-input-border-color);
padding: var(--sl-spacing-x-small);
}
}
calendar-month {
--color-accent: var(--sl-color-primary-600);
&::part(button) {
border-radius: var(--sl-input-border-radius-medium);
}
&::part(range-inner) {
border-radius: 0;
}
&::part(range-start) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&::part(range-end) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
&::part(range-start range-end) {
border-radius: var(--sl-input-border-radius-medium);
}
}
</style>
<div class="flex gap-xs align-end">
<sl-input label="Date range" class="flex-1"></sl-input>
<sl-button id="toggle-button">
<sl-icon name="calendar-range" label="Toggle calendar"></sl-icon>
</sl-button>
</div>
<sl-popup placement="bottom-end" distance="8" anchor="toggle-button" auto-size="horizontal">
<sl-card style="max-inline-size: calc(var(--auto-size-available-width) - 20px)">
<calendar-range months="2">
<sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
<sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
<div class="flex gap-l flex-wrap justify-center">
<calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
</div>
</calendar-range>
</sl-card>
</sl-popup>
<script>
const root = document.currentScript.closest(".example");
const input = root.querySelector("sl-input");
const popup = root.querySelector("sl-popup");
const toggle = root.querySelector("sl-button");
const calendar = root.querySelector("calendar-range");
function open() {
popup.active = true;
requestAnimationFrame(() => calendar.focus());
}
function close() {
popup.active = false;
toggle.focus();
}
toggle.addEventListener("click", () => {
popup.active ? close() : open();
});
calendar.addEventListener("keydown", (e) => {
if (e.key === "Escape") close();
});
calendar.addEventListener("change", (e) => {
input.value = e.target.value;
close();
});
input.addEventListener("input", (e) => {
calendar.value = e.target.value;
});
</script>
Finally
And we are done! Hopefully this hasn't been too daunting. If you are using a front-end framework like react or vue, you would likely wrap this up in a reusable component, adapting the javascript as necessary.
We haven't paid any mind to mobile styles throughout this guide. On mobile, perhaps you'd want to only show one month at a time. Or render an overlay rather than a popup. These decisions are beyond the scope of this guide, and are left as an exercise for the reader.
In terms of accessibility there is more that can be done. We should add
aria-expanded
to the toggle button, to communicate whether the popup
is open or closed. Additionally, it might be helpful to communicate when a range
selection has started and ended via a polite aria-live
region. This
could be achieved with the rangestart
and rangeend
events.
We could also add a hint to the input via aria-describedby
to
describe the expected format for manual entry.
To find out more about each component, please see the respective component API docs. Or visit the theming guide for a more in-depth look at styling the components.