CSS Calendar
A monthly calendar built with CSS Grid: seven equal columns, hover states, today highlighted, no JavaScript.
A CSS calendar is a grid with seven equal columns, and almost everything else follows from that one declaration. grid-template-columns: repeat(7, 1fr) gives the week its shape, auto placement fills the dates in order, and a single grid-column value on the first of the month pushes the whole run into the right weekday. There is no layout maths to do and no script.
The month view below is the common case. Two more grids follow: a week view where meetings span several hour rows at once, and a year heat grid built by turning the flow direction sideways so a whole year fits in seven rows.
A month in seven columns
HTML
<div class="cal-wrap">
<div class="cal-header">
<span class="cal-month-label">May 2026</span>
<div class="cal-nav">
<button class="cal-nav-btn" aria-label="Previous month">‹</button>
<button class="cal-nav-btn" aria-label="Next month">›</button>
</div>
</div>
<div class="cal-grid">
<span class="cal-day-name">Su</span>
<span class="cal-day-name">Mo</span>
<span class="cal-day-name">Tu</span>
<span class="cal-day-name">We</span>
<span class="cal-day-name">Th</span>
<span class="cal-day-name">Fr</span>
<span class="cal-day-name">Sa</span>
<!-- May 2026 starts on Friday, so the 1st is placed in column 6 -->
<span class="cal-day cal-muted" style="grid-column: 1"></span>
<span class="cal-day cal-muted" style="grid-column: 2"></span>
<span class="cal-day cal-muted" style="grid-column: 3"></span>
<span class="cal-day cal-muted" style="grid-column: 4"></span>
<span class="cal-day cal-muted" style="grid-column: 5"></span>
<span class="cal-day" style="grid-column: 6">1</span>
<span class="cal-day">2</span>
<span class="cal-day">3</span>
<span class="cal-day">4</span>
<span class="cal-day">5</span>
<span class="cal-day cal-has-event">6</span>
<span class="cal-day">7</span>
<span class="cal-day">8</span>
<span class="cal-day">9</span>
<span class="cal-day">10</span>
<span class="cal-day">11</span>
<span class="cal-day">12</span>
<span class="cal-day cal-has-event">13</span>
<span class="cal-day">14</span>
<span class="cal-day">15</span>
<span class="cal-day">16</span>
<span class="cal-day">17</span>
<span class="cal-day">18</span>
<span class="cal-day">19</span>
<span class="cal-day cal-today cal-has-event">20</span>
<span class="cal-day">21</span>
<span class="cal-day">22</span>
<span class="cal-day">23</span>
<span class="cal-day">24</span>
<span class="cal-day">25</span>
<span class="cal-day">26</span>
<span class="cal-day">27</span>
<span class="cal-day">28</span>
<span class="cal-day">29</span>
<span class="cal-day">30</span>
<span class="cal-day">31</span>
</div>
<div class="cal-footer">
<span class="cal-legend cal-legend-today">Today</span>
<span class="cal-legend">Event</span>
</div>
</div>
CSS
.cal-wrap {
width: 100%;
max-width: 380px;
margin: 0 auto;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
overflow: hidden;
}
.cal-header {
padding: 1rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #2a2a2d;
}
.cal-month-label {
font-weight: 700;
font-size: .95rem;
color: #f0f0f0;
}
.cal-nav {
display: flex;
gap: .4rem;
}
.cal-nav-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid #2a2a2d;
border-radius: 8px;
color: #88888f;
font-size: .8rem;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.cal-nav-btn:hover {
border-color: #b8ff57;
color: #b8ff57;
}
/* Day names and dates share one seven-column grid */
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: .25rem .5rem .75rem;
}
.cal-day-name {
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .58rem;
text-transform: uppercase;
letter-spacing: .07em;
color: #88888f;
text-align: center;
padding: .6rem .25rem .4rem;
}
/* position: relative anchors the event dot */
.cal-day {
position: relative;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
margin: .1rem;
border-radius: 50%;
font-size: .8rem;
color: #f0f0f0;
cursor: pointer;
transition: background .15s, color .15s;
}
.cal-day:hover {
background: #1c1c1e;
}
.cal-day.cal-today {
background: #b8ff57;
color: #0c0c0d;
font-weight: 700;
}
.cal-day.cal-today:hover {
background: #b8ff57;
}
.cal-day.cal-muted {
color: #88888f;
opacity: .35;
}
.cal-day.cal-has-event::after {
content: "";
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 3px;
height: 3px;
border-radius: 50%;
background: #b8ff57;
}
.cal-day.cal-has-event.cal-today::after {
background: #0c0c0d;
}
.cal-footer {
display: flex;
gap: 1.25rem;
padding: .6rem 1rem;
border-top: 1px solid #2a2a2d;
}
.cal-legend {
display: flex;
align-items: center;
gap: .4rem;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .62rem;
color: #88888f;
letter-spacing: .04em;
}
.cal-legend::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: #b8ff57;
}
Other ways to build it
Week view, with events spanning hour rows
The same grid turned on its side. Columns become days, rows become time slots, and an event is one item placed on both axes at once with grid-column and grid-row: 2 / span 3. The hour labels only declare a column, so auto placement drops each one into the next free row without any row numbers being written down. The faint horizontal rules are a repeating gradient on the grid itself rather than borders on cells that do not exist.
HTML
<div class="cal-week-grid">
<span class="cal-hour">9</span>
<span class="cal-hour">10</span>
<!-- a column only, so each label auto places into the next row -->
<div class="cal-event" style="grid-column:3;grid-row:2 / span 3">
Design review
</div>
</div>
CSS
.cal-week-grid {
display: grid;
/* a narrow gutter for the hours, then one track per weekday */
grid-template-columns: 30px repeat(5, 1fr);
grid-template-rows: repeat(6, 34px);
gap: 2px;
/* the hour rules are drawn, not bordered: one gradient, no extra cells */
background-image: repeating-linear-gradient(
to bottom, #2a2a2d 0 1px, transparent 1px 36px);
}
.cal-hour {
grid-column: 1;
font-size: .6rem;
color: #88888f;
text-align: right;
padding-right: .3rem;
}
.cal-event {
border-radius: 6px;
padding: .3rem .45rem;
font-size: .65rem;
overflow: hidden;
}
A year in seven rows
Setting grid-auto-flow: column reverses the fill order, so items run down each column before starting the next one. Fix the row count at seven and every column becomes a week, which is the whole trick behind an activity heat grid. The template declares rows and lets columns be created implicitly with grid-auto-columns, so adding another year of cells needs no change to the CSS at all.
HTML
<div class="cal-year-wrap">
<div class="cal-year"><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-3"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-2"></span><span class="cal-heat cal-heat-4"></span><span class="cal-heat cal-heat-1"></span><span class="cal-heat cal-heat-0"></span><span class="cal-heat cal-heat-2"></span></div>
<div class="cal-year-key">
<span>Less</span>
<span class="cal-heat cal-heat-0"></span>
<span class="cal-heat cal-heat-1"></span>
<span class="cal-heat cal-heat-2"></span>
<span class="cal-heat cal-heat-3"></span>
<span class="cal-heat cal-heat-4"></span>
<span>More</span>
</div>
</div>
CSS
.cal-year {
display: grid;
/* seven rows are declared; the columns are created as cells arrive */
grid-template-rows: repeat(7, 11px);
grid-auto-flow: column;
grid-auto-columns: 11px;
gap: 3px;
overflow-x: auto;
}
.cal-heat {
border-radius: 2px;
background: #b8ff57;
}
/* one hue, five weights: opacity keeps this working in either theme */
.cal-heat-0 { background: #1c1c1e; }
.cal-heat-1 { opacity: .22; }
.cal-heat-2 { opacity: .45; }
.cal-heat-3 { opacity: .7; }
.cal-heat-4 { opacity: 1; }
How it works
grid-template-columns: repeat(7, 1fr) creates seven equal columns. Day name headers and date cells flow into the same grid, and the day numbers start in the correct weekday column because the first date cell carries a grid-column offset. aspect-ratio: 1 keeps every date cell square whatever the container width. The today highlight and the event dot are class targeting, nothing more.
The grid holds two kinds of child and never needs to be told them apart. Seven day name spans come first and fill row 1 exactly, which is what leaves the dates starting cleanly on row 2. Nothing declares a row count, because rows are created implicitly as items arrive, so a 28 day February and a 31 day month that starts on a Saturday both work from the same stylesheet.
Placing the first of the month is the only calculation in the whole pattern. grid-column: 6 on the 1st sends it to the Friday column, and every date after it auto places into the next free cell, wrapping to a new row at the end of each week. The empty muted cells before it in this demo are cosmetic. They exist to draw the trailing days of the previous month and could be removed entirely without moving a single date.
aspect-ratio: 1 on a date cell resolves against the width the 1fr column already gave it, so each cell is square at any container size and the calendar reflows without a media query. Because the cell also carries border-radius: 50%, the today highlight is a circle rather than a rounded rectangle, and it stays circular as the panel narrows.
The event dot is an ::after on the cell. position: relative on the cell is what makes the absolute positioning of the dot resolve against the day rather than against the page, and the left: 50% with translateX(-50%) centers it without knowing the cell's width. This is the same trick used for the arrow on a CSS hover tooltip.
One thing grid will not do for you is style the weekend columns with :nth-child. Once the month is offset, DOM order and column position stop agreeing, so :nth-child(7n) lands on a different weekday every month. Either emit a class from whatever renders the markup, or place a full height band into the weekend columns as its own grid item and push it behind the dates with z-index. Panel layouts have the same relationship between structure and styling, covered on CSS split panel.
CSS properties used
grid-template-columnsrepeat(7, 1fr)divides the available width into seven equal tracks. The1frunit is what makes the calendar fluid rather than fixed.grid-column- Sends an item to a specific column. On the first of the month it sets the weekday offset, and everything after it auto places from there.
grid-row- Combined with
span, it lets one item cover several rows at once, which is how a two hour meeting is drawn in the week view. grid-auto-flowcolumnfills down each column before moving right, which turns a seven row grid into a year of weeks.aspect-ratio1keeps every date cell square by deriving its height from the width the column gave it. No fixed pixel size is needed.positionrelativeon the date cell is what anchors the absolutely positioned event dot to the day rather than to the nearest positioned ancestor further up.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
grid | 57 | 52 | 10.1 | 16 |
flexbox | 21 | 28 | 6.1 | 12 |
custom properties | 49 | 31 | 10 | 16 |
generated content | 4 | 2 | 3.1 | 12 |
repeating gradients | 10 | 3.6 | 5.1 | 12 |
Figures come from Can I Use for CSS Grid Layout level 1, Flexible Box Layout, CSS Variables, generated content on pseudo-elements and repeating gradients. Can I Use has no separate feature entry for aspect-ratio, so it is left out of the table rather than given a version from memory. Where it is missing, the fallback is a percentage padding-top on the cell, which produces the same square from the same width. Subgrid is not used anywhere on this page, so nothing here needs a browser newer than 2017.
Accessibility notes
A grid of <span> elements is a picture of a calendar, not a calendar. Screen readers get a stream of loose numbers with no row or column relationship, and no way to tell Tuesday from Thursday. For anything a reader has to operate rather than glance at, build it as a real <table> with <th scope="col"> on the day names, then apply display: grid to the table if the grid layout matters. The semantics survive, because the accessibility tree keeps the table roles.
Every date cell in the demo has cursor: pointer and a hover state, which promises interactivity that plain spans do not deliver. Either make the cells <button> elements so they can be reached with Tab and activated with Enter, or drop the pointer cursor. A control that looks clickable and is not reachable from a keyboard is worse than a flat display.
The event dot carries meaning through a small colored circle alone, which fails for anyone who cannot see it. Add the information to the cell as text, using a visually hidden span such as "2 events", and treat the dot as reinforcement. Similarly, the today highlight should not rely on background color alone: give the cell aria-current="date" so it is announced, not only painted.
What you can build with it
- Date pickers. The month grid under an input, where the layout is CSS and only the selection state needs any script.
- Availability views. Booked and free days marked with a class, so a reader sees the shape of a month at a glance before opening anything.
- Team schedules. The week view, with one column per person or per day and meetings spanning the hour rows they actually occupy.
- Activity heat grids. A year of daily counts in seven rows, the pattern used for commit graphs and habit trackers.
- Editorial calendars. Publication dates across a quarter, with color bands for each content type and no table library involved.
Mistakes worth avoiding
- Hard coding a row count with
grid-template-rows. Months need five or six week rows depending on their length and start day, and a fixed count clips the last week of the long ones. - Using
:nth-childto shade weekends. It works for a month that starts on Sunday and silently targets the wrong column for every other month. - Padding the start of the month with empty cells and then also setting
grid-columnon the first date. Both are offsets, so the month lands a week further along than intended. - Setting a fixed pixel height on the date cells. It defeats the fluid columns and produces rectangles on narrow screens, where
aspect-ratiowould have kept them square. - Leaving
position: relativeoff the date cell. The event dot then positions against whatever ancestor happens to be positioned, and every dot in the month piles up in one corner.
Frequently asked questions
How do I start a CSS calendar on the correct weekday?
grid-column on the first date cell to the column number for that weekday, counting the first column as 1. Every later date auto places into the next free cell, so only that one value changes from month to month. No filler elements are needed unless you want to draw the previous month's trailing days.How do I make calendar cells square?
aspect-ratio: 1 on the cell. The 1fr column gives it a width, and the ratio derives the height from that, so the cells stay square as the container changes. Where aspect-ratio is unavailable, padding-top: 100% on a wrapper does the same job.Can I build a week view with CSS Grid?
grid-template-rows to a fixed row per time slot, then place each event with grid-row: 2 / span 3. The week variant on this page does exactly that, and an event that runs from ten to half past eleven is one item spanning three half hour rows.Is a CSS Grid calendar accessible?
<table> with proper header cells, mark today with aria-current="date", and use real buttons for any cell the reader is meant to activate.