Fully responsive CSS only slide system

October 14, 2022


This is something that i wanted to do for a long time, and finally with css container queries i think its possible to do a fully responsive slide system with only css and no javascript. Or at least, not for displaying. I will use it to parse markdown to html.

In this post i will try to break some of the things that made this possible, and some tricks that i found along the road.


First of all, we need to make slides slidable. (its that even a word?). For this, i will use CSS scroll snap feature. I think of slides as a vertical carousel. Each slide will be a <section> tag which will be snapable. There are a lot of tutorials online about this, the ones i read are: CSS-Only Carousel and Building a Stories component. Here im also using the aspect-ratio property to define its size.

	<section>My slide 1</section>
	<section>My slide 2</section>
	<section>My slide 3</section>
	<section>My slide 4</section>
main {
	width: 640px;
	aspect-ratio: 16/9;
	scroll-snap-type: y mandatory;
	overflow-y: auto;

section {
	scroll-snap-align: center;
	width: 100%;
	aspect-ratio: 16/9;
My slide 1
My slide 2
My slide 3
My slide 4

If your browser supports it, whenever you scroll in the demo above, you should advance per slide, and not be in between two.

I will also add the overscroll-behavior property to trap scrolling and prevent the outer page from scrolling if im in the first or last slide. And the scrollbar-gutter one to reserve space for the scrollbar.

main {
	/* ... */
	overscroll-behavior-y: contain;
	scrollbar-gutter: auto;
	/* ... */

Removing scrollbar

Now for personal preference, i want to have the possibility to remove the scrollbar if i want. For this, i will use the scrollbar-width (only available in firefox) and the ::-webkit-scrollbar pseudo-element whenever my main tag has the no-scrollbar attribute.

main[no-scrollbar]::-webkit-scrollbar {
	display: none;

	scrollbar-width: none;

Fluid Typography

Here comes the juicy part. I will follow Easy Fluid Typography With clamp() Using Sass Functions with some changes to make it fully contained.

The problem with this solution, is that it depends on the vw unit, making it responsible around the viewport width, and not the element. Here is where container queries comes to the rescue. They allow us to use the cqw unit to reference the container width, and not the viewport.

Lets begin defining our element as a container element. We could use container-type: inline-size, but we need measurement in both direction for something that we will do in the future. I started this way, and lost too much time until i realized that if you use inline-size your height measurements are incorrect. Because we are using the aspect-ratio property, we wont have any problem that our element doesnt have a defined fixed size.

main {
	container-type: size;
	container-name: slide-container;
	min-width: 320px;

Now, to the typography part. As there are a lot of moving parts, im using css variables (although they are called custom properties) to try to put some order and extensibility.

main {
	/* Sizes are designed to be in a 1024px canvas */
	/* 1920px */
	--_max-scaler: 1.875;

	/* 320px */
	--_min-scaler: 0.3125;

	--h1-size: 72;
	--h1-size__min: calc( var(--h1-size) * var(--_min-scaler));
	--h1-size__max: calc( var(--h1-size) * var(--_max-scaler));
	/* 1200 = 1920px - 320px in pt */
	--h1-size__fluid-slope: calc( (var(--h1-size__max) - var(--h1-size__min)) / 1200 );
	/* 240 = 320px in pt */
	--h1-size__fluid-intercept: calc( var(--h1-size__min) - (var(--h1-size__fluid-slope) * 240));
	/* cqw to use container inline size, instead of viewport */
	--h1-size__fluid: calc( (var(--h1-size__fluid-slope) * 100 * 1cqw) + (var(--h1-size__fluid-intercept) * 1pt));

The slides will be designed in a 1024px width canvas size, and be displayed in a space from 320px to 1920px.

The –_min-scaler and –_max-scaler are the ratios needed to convert some value in a 1024 canvas to a 320 or 1920 one.

–h1-size is the size of a h1 tag in a 1024px canvas. Its expressed in pt, but we need to define it unitless because when we use calc, at least one of the members needs to be a number.

The –h1-size__min and –h1-size__max are the sizes of a h1 in a 320 or 1920 canvas (linearly interpolated).

The –h1-size__fluid-slope, –h1-size__fluid-intercept and –h1-size__fluid are the same thing as in the Smashing Magazine article. 1200 is the diference in viewports size expressed in pts. Same for 240. The * 1pt is needed to convert the number to a length.

Because everything is a custom property, if we want to change the size of an h1 tag, we only need to update the property, and everything will react accordingly.

Now we do the same for h2, single text, and some padding values, and we have a fluid typography and spacing system that scales with the size of the element where its embedded.

Free container size

With this, we already have a fully responsive slide system. But there is one more thing we could do, and i think its the cherry on the top. What if we allow the container to be whatever size it wants to be, and we scale the slides to fit inside it. Like object-fit but for our html-elements-slides.

This is i think the most difficult part, at least for me. This is why im writing this post, to try to order my thoughts and findings about this part.

First of all, we need to change the structure of our slides. We need to wrap them inside a div and make the section fill all the available space. Also center its contents and remove its aspect-ratio property, as we are going to scale them ourselves.

section {
	width: 100%;
	height: 100%;
	display: grid;
	place-items: center;
	/* aspect-ratio: 16/9; */
	<section><div class="wrapper">My slide 1</div></section>
	<section><div class="wrapper">My slide 2</div></section>
	<section><div class="wrapper">My slide 3</div></section>
	<section><div class="wrapper">My slide 4</div></section>

For our wrapper class, we need the following, which im going to try to explain as best as i can.

.wrapper {
	height: calc((var(--_is-height) * 100) + (var(--_is-width) * (9*100/16)));
	width: calc((var(--_is-width) * 100) + (var(--_is-height) * (16*100/9)));

First of all, –_is-width and –_is-height are defined as following in main

main {
	--_is-height: clamp(0cqh,((100cqw * 9) - (100cqh * 16)) * 100,1cqh);
	--_is-width: clamp(0cqw,((100cqw * 9) - (100cqh * 16)) * -100,1cqw);

They all came from this stackoverflow answer which explains how to fit an image inside a container with a different aspect ratio.

ratio_image = width_image / height_image
ratio_screen = width_screen / height_screen

if ( ratio_screen > ratio_image ){
	width_image = width_image * height_screen / height_image
} else {
	height_image = height_image * width_screen / width_image

First of all, we want to check that ratio_screen > ratio_image, which could be rewritten as following.

And because everything is positive numbers, the following rewrite is valid. Also because we are working with ratios, we could use (16,9) as the image size.

Unluckily css doesnt have comparision functions yet. But we can still rewrite this expresion in the following way.

If width_screen*9 is greater than 16*height_screen this will be positive number, and we want to have some kind of true value. If not, we want a false value. The default number values for true and false in the programming world are 1 and 0. So this seems to be a work for clamp. If its a positive number, we want the property to be 1cqh, and if not, we want it to be 0cqh.

But our value could be a number between 0 and 1, and we want it to be a 1 in that case, and clamp wont make it. Thats why we multiply it by 100. I think two decimal places of accuracy is ok for this. If not, one can always add more zeros.

I called it --_is-height because i wanted to reflect that if its true, we want the slide to have 100% of the container height.

For --_is-width is exactly the same thought. We only multiply it by -100, to express the inequality in the other way.

Its very important to notice that this properties have units in them, and are not unitless

Now that we can check if its greater or not, the only thing left to do, is change our width and height. I will explain only one, as the other is the same.

If --_is-height is 1cqh, we need to use 100% of the container’s height for the slides and calculate the width ourselves. Multiplying it by 100, we would have 100cqh which is 100% of the container’s height.

If its 0cqh, that means we need to calculate the height to keep the aspect ratio. The previous part will be 0 (0cqh*100), so we could add the new height to this.

As we saw in the pseudocode, its value should be height_image * width_screen / width_image. We already have height_image and width_image (16,9) as explained before. So our final calculation its 9 * width_screen / 16. But width_screen could be obtained by multiplying –_is-width*100 because if --_is-height its 0, --_is-width should be 1cqw.

And with this, we finally have a fully responsive slide system respecting its parent size. Its important that we dont remove the aspect-ratio property from the main. Because if it doesnt have a width or height, it could be calculate from there.

Updating typography

The last thing we need to do, is update our fluid typography. Because we were using the container’s width as unit, it will not longer work, because now, we need to use our “slide width”, wich could be smaller. But this is easy to solve. We define another custom property with the value of the slide’s width, and use that one, instead of the 1cqw of before. We also need to remove the *100 because our new custom property is already multiplied

main {
	--slide_width: calc((var(--_is-width) * 100) + (var(--_is-height) * (16*100/9)));
	--h1-size__fluid: calc( (var(--h1-size__fluid-slope) * var(--slide_width)) + (var(--h1-size__fluid-intercept) * 1pt));


We saw that the current state of css features its SO powerful. Using container queries, css scroll snap, aspect-ratio and math functions, all new and shiny things, we built something that in the past would require javascript. Althought the version i uploaded to github uses javascript, its only needed for parsing the markdown and having a custom element. I believe that using something like enhance you could remove all js completly.

If you want you could read the source code

Currently this only works in Chrome and i believe Safari. But it shouldnt be long untill my browser of choice, Firefox catches up.

Leave your comment on the github issue, sending me an email or DMing me on twitter