Skip to content

The CSS / Utility hybrid approach with Tailwind v4

The CSS / Utility hybrid approach with Tailwind v4

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Just recently, the progress on the next major version of Tailwind CSS was open-sourced, along with a preview post. This is very exciting news to me, and I love the direction they’ve taken with this release. Tailwind can be a pretty polarizing topic among developers, but I believe that we can find some common ground and live in harmony. In this post, we’ll look at how the CSS + Tailwind hybrid approach is getting an upgrade in v4 and some of the patterns that we can take advantage of to provide a stellar styling experience on our websites.

The hybrid approach

It doesn’t matter what your personal take on Tailwind is, the fact is utility classes are an incredibly useful pattern for styling websites with CSS. I’m not sold on the hard-line, all-or-nothing approach that Tailwind has always recommended. Having a class string that extends way off the screen on an ultra-wide monitor is not pretty. Combining traditional CSS classes with utility classes helps cut down on the styling noise in your markup and provides some advantages when applied correctly. This post will cover some useful patterns and best practices for using CSS and Tailwind in harmony

Tailwind going “CSS-native”

In the v4 introduction post, they state

A major goal of Tailwind CSS v4.0 is making the framework feel CSS-native, and less like a JavaScript library.

I love that they are heading in this direction, and I’m imagining Tailwind as the toolkit for styling websites with CSS. It is going to replace the plethora of tools from the past and provide support for all the modern niceties like:

  • Importing CSS files with @import
  • Vendor prefixing (no more autoprefixr)
  • Improved browser support for modern features like CSS nesting and media query ranges.

This means less worry for us as developers. We can write our code and build our sites using the modern features that the platform provides without sweating all the other details and edge cases. The talented folks at Tailwind and the open-source community have our backs.

Tailwind v4 CSS configuration

The biggest step in the “CSS-native” direction and the feature that I’m most excited about is the CSS configuration. This is great for embracing CSS and Tailwind as one instead of two factions at battle. If you’ve been using Tailwind exclusively for a long time like I have, you might be out of touch with all the great features and tools that CSS provides out of the box now. This is a great opportunity to get back to learning and using the platform.

In Tailwind v4 we do our configuration and theming with CSS variables. To get an idea of what this looks like check out the default theme on GitHub. In our configuration we can override any values from the default theme or extend it with new values.

@import "tailwindcss";

@theme {
  --color-*: initial;

  --color-gray-50: #f8fafc;
  --color-gray-100: #f1f5f9;
  --color-gray-200: #e2e8f0;

  --color-green-800: #3f6212;
  --color-green-900: #365314;
  --color-green-950: #1a2e05;
}

All the values from the theme are exposed as CSS variables. We can reference them in our CSS files or directly in our utility classes.

.badge--red {
	background-color: var(--color-red-50);
	color: var(--color-red-600);
}
<div class="p-[calc(var(--spacing-6)-1px)]">
  <!-- ... -->
</div>

Best practices for combining CSS with utility classes

I can understand why Tailwind recommends using strictly utility classes. It’s easy to shoot yourself in the foot and run into the issues that utility classes set out to solve in the first place. I believe if you follow some general guidelines you can safely have the best of both worlds.

Create focused/specialized classes

CSS tends to get messy when we use classes that are overly specific with styles that apply to just one or a small number of use cases.

Take a calendar where the days are custom checkboxes. You might have some pretty specialized styles for this bit of UI.

.calendar-date {
	color: var(--color-gray-300);
	background-color: var(--color-gray-800);

	&:hover, 
	&:has(input[type="checkbox"]:focus) {
		background-color: var(--color-gray-700);
	}

	& input[type="checkbox"]:checked + time {
		background-color: var(--color-indigo-600);
		color: var(--color-white);
	}
}

Notice that we just add some specific styles that apply to this use case. This gives us the flexibility to keep composing our styles with utility classes.

Some things are better left to utilities

The best way to avoid wandering too far off the path of enlightenment is to stick with utility classes for certain (most?) things. Spacing and layouting are examples of things that are probably best left to utility classes. These typically vary all across a page, and encoding them in a class will make it a lot less flexible.

A code smell to look out for: If you have abstracted something to a class and you end up overriding certain aspects of its styles in different places. Those styles should probably be utility classes.

Classes as components

Tailwind has always recommended using a component system for your UI components so that your utility classes are all defined in a single place. You don’t want to have to edit 50 different files to make a change to the style of your buttons across your website. I’ve found that defining things that might be simple “components” like buttons, badges, links, etc as classes works just as well in most cases.

You can use Tailwind CSS with the @apply directive or traditional class definitions for this:

.badge {
	 @apply inline-flex items-center gap-x-1.5 rounded-md py-0.5 text-sm/5 font-medium sm:text-xs/5;
}

You can even bring the BEM naming convention back to compose variants on top of base component styles. (or name them however you’d like!)

.badge--yellow {
	@apply bg-yellow-400/20 text-yellow-700 ;
}

Now we can easily define badges in our markup:

<span class="badge badge--yellow">Warning</span>

Modern CSS and other interesting utility hybrid patterns

I think a lot of us are realizing that modern CSS is pretty great. There are a ton of awesome new features gaining support across major browsers, like CSS nesting, container queries, and more! More and more people are also coming around to the utility of utility classes. If you want to dive deeper and see other interesting approaches and patterns when combining modern CSS with utility classes, check out the post: Modern CSS patterns in Campfire.

Summary

I’m looking forward to the Tailwind CSS configuration and other awesome updates coming in v4. Using Tailwind doesn’t have to be a one-size-fits-all approach. If you want to write some CSS, I say go for it! Hopefully, some of the points in this post will lead to success if you decide to do so.

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

You might also like

How to Truncate Strings Easily with CSS cover image

How to Truncate Strings Easily with CSS

You'll often need to truncate text when working with user interfaces, especially when displaying content within a limited space. CSS provides a straightforward way to handle this scenario, ensuring that long text strings are cut off gracefully without affecting the overall layout. CSS Truncation Techniques Single-Line Truncation If you want to truncate a single line of text, CSS provides a simple solution. The key properties to use here are overflow, white-space, and text-overflow. ` Explanation of properties: * white-space: nowrap: This ensures the text stays on a single line, preventing wrapping. * overflow: hidden: This hides any content that overflows the container. * text-overflow: ellipsis: This adds the ellipsis (…) at the end of the truncated text. Multi-Line Truncation While truncating a single line of text is common, sometimes you may want to display multiple lines but still cut off the text when it exceeds a certain number of lines. You can use a combination of CSS properties such as -webkit-line-clamp along with the display and overflow properties. ` Live example: Explanation of properties: * display: -webkit-box: This is a legacy flexbox-like display property that works with the -webkit-line-clamp property. * -webkit-line-clamp: Specifies the number of lines to show before truncating. * -webkit-box-orient: vertical: Ensures the box is laid out vertically for proper multi-line truncation. * overflow: hidden: Prevents content overflow. * text-overflow: ellipsis: Adds an ellipsis after the truncated content. Why Use CSS for Text Truncation Over JavaScript Techniques? While it’s possible to truncate text using JavaScript, CSS is often a better choice for this task for several reasons. Let's explore why CSS-based truncation techniques are generally preferred over JavaScript. Performance Efficiency CSS operates directly within the browser's layout engine, meaning it doesn’t require additional processing or event handling as JavaScript does. When using JavaScript to truncate text, the script needs to run on page load (or after DOM manipulation), and sometimes, it needs to listen for events such as window resizing to adjust truncation. This can introduce unnecessary overhead, especially in complex or resource-constrained environments like mobile devices. CSS, on the other hand, is declarative. Once applied, it allows the browser to handle text rendering without any further execution or processing. This leads to faster load times and a smoother user experience. Simplicity and Maintainability CSS solutions are much simpler to implement and maintain than their JavaScript counterparts. All it takes is a few lines of CSS to implement truncation. In contrast, a JavaScript solution would require you to write and maintain a function that manually trims strings, inserts ellipses, and re-adjusts the text whenever the window is resized. Here's the JavaScript Truncation Example to compare the complexity: JavaScript Truncation Example: ` At the example above, we truncated the text to 50 characters which may be 1 line on large screens and 6 lines on mobile and in that case we will need to add more code to truncate it responsively. As you can see, the CSS solution we used earlier is more concise and readable, whereas the JavaScript version is more verbose and requires managing the string length manually. Responsiveness Without Extra Code With CSS, truncation can adapt automatically to different screen sizes and layouts. You can use relative units (like percentages or vw/vh), media queries, or flexbox/grid properties to ensure the text truncates appropriately in various contexts. If you were using JavaScript, you’d need to write additional logic to detect changes in the viewport size and update the truncation manually. This would likely involve adding event listeners for window resize events, which can degrade performance and lead to more complex code. CSS Example for Responsive Truncation: ` To achieve this in JavaScript, you’d need to add more code to handle the width adjustments dynamically, making it more complex to maintain and troubleshoot. Separation of Concerns CSS handles the presentation layer of your website, while JavaScript should focus on dynamic functionality or data manipulation. By keeping truncation logic within your CSS, you're adhering to the principle of separation of concerns, where each layer of your web application has a clear, well-defined role. Using JavaScript for visual tasks like truncation mixes these concerns, making your codebase harder to maintain, debug, and scale. CSS is purpose-built for layout and visual control, and truncation is naturally a part of that domain. Browser Support and Cross-Browser Consistency Modern CSS properties like text-overflow and -webkit-line-clamp are widely supported across all major browsers. This means that CSS solutions for truncation are generally consistent and reliable. JavaScript solutions, on the other hand, may behave differently depending on the browser environment and require additional testing and handling for cross-browser compatibility. While older browsers may not support specific CSS truncation techniques (e.g., multi-line truncation), fallback options (like single-line truncation) can be easily managed through CSS alone. With JavaScript, more complex logic might be required to handle such situations. Reduced Risk of Layout Shifting JavaScript-based text truncation risks causing layout shifting, especially during initial page loads or window resizes. The browser may need to recalculate the layout multiple times, leading to content flashing or jumpy behavior as text truncation is applied. CSS-based truncation is applied as part of the browser’s natural rendering flow, eliminating this risk and ensuring a smoother experience for the user. Conclusion CSS is the optimal solution for truncating text in most cases due to its simplicity, efficiency, and responsiveness. It leverages the power of the browser’s rendering engine, avoids the overhead of JavaScript, and keeps your code clean and maintainable. While JavaScript truncation has its use cases, CSS should always be your go-to solution for truncating strings, especially in static or predictable layouts. If you like this post, check out the other CSS posts on our blog!...

:where functional pseudo-selectors :is valuable in CSS cover image

:where functional pseudo-selectors :is valuable in CSS

If you’ve written CSS before, you’ve used pseudo selectors. Typically, we’d use them to style :hover events or select every :nth-of-type() In recent years CSS has become a lot more powerful, and with that, we now have many more pseudo-selectors available to use. Let’s explore some functional pseudo-selectors together and see how to use them to enhance our front-end code. Functional pseudo-classes While there are a wide range of pseudo-classes, I want to focus on the _functional_ ones today. :is :is works very similar to a regular CSS class list at first glance ` One of its main benefits is that you can group CSS classes to form more readable and maintainable conditions. ` For deep nesting, this can make your CSS significantly easier to understand, simplifying editing at a later date. ` You may be thinking that CSS has another solution that improves readability in a similar way: nesting. That’s true, and in this context, you can use them interchangeably, though the syntax is still shorter. ` However deeper nesting using nested CSS can become complex quickly. Sometimes it makes sense to use :is to help avoid this complexity. ` :is also provides a specificity modifier you don’t get with nested css. If you write: ` Every selector within the:is would be treated as if it had the same specificity value as the ID. With :is, the highest value applies to all other values within the :is. This does not apply to nested CSS. :not It does exactly what you think it does. The :not selector grabs everything _except_ the classes you define: ` Like the other pseudo-classes, you can negate multiple classes if you choose: ` This can be powerful when combined with other pseudo-selectors: ` :where Using the :where pseudo-class is great for creating low-specificity CSS. If you create a library or plugin that’s going to be used by other people and you want them to style it themselves, but don’t want it to appear ugly when they first set it up, :where is the way to do that. For example, let’s style all our links with an underline using :where ` With this approach, we can override our link’s default styles without having to create difficult to maintain CSS: ` If we didn't use :where above, import order would matter if we wanted to override this. But because we planned ahead, we can just use a standard a tag. Without using :where we’re stuck with options that are much harder to work with: ` :where also has the same benefits of class grouping that :is does but without the specificity, so you can do something like this and then override it easily later. ` :has One of the most powerful pseudo selectors is :has, which gives you the option to style a tag or class based on other classes or tags associated with it. ` An incredible benefit :has brings is the ability to create parent selector functionality. This gives you a wide variety of options to style the parent based on the state of the children. ` You can also combine this with the :not selector to select elements that don’t have specific children. ` Benefits of Pseudo-Classes Readability One of the benefits of all of these pseudo-selectors is that they can act similarly to nested CSS. It’s possible to use these to make your code more readable. ` Using :is this way is effectively the same as using nested CSS, but if you need lower specificity you could use :where, or :not if you want to exclude (instead of include) some classes. Functionality :not and :has provide new options which weren’t possible before, allowing you to style more dynamically and provide better experiences while simplifying your code. Before these options were available, the only solution was to style the code using JavaScript. While this technically allows you to achieve your styling goals, it’s not ideal. Mixing CSS operations into JS files makes it much harder to maintain long-term because it adds a layer of abstraction to your solution, while the built-in CSS option is much simpler. While :is and :where don’t provide as much new functionality, they still allow you to write more understandable CSS with less ambiguity or workarounds, making maintenance significantly easier. Summing up Modern CSS allows us to be much more flexible with our styles, all while writing less code. Simplifying CSS and removing the need to compensate, either by writing extra CSS or styling through JS, means our CSS files are more explicit and easier to maintain in the long term. They may not be needed often, but they’re an incredibly important part of your front-end toolbelt. Have you found any interesting ways to use functional pseudo-classes? Send us a post on X or message us on LinkedIn and show me what cool things you’ve made....

D1 SQLite: Schema, migrations and seeds cover image

D1 SQLite: Schema, migrations and seeds

I’ve written posts about some of the popular ORM’s in TypeScript and covered their pros and cons. Prisma is probably the most well known and Drizzle is a really popular up and comer. I like and use ORM’s in most of my projects but there’s also a camp of folks who believe they shouldn’t be used. I started a small Cloudflare Workers project recently and decided to try using their D1 SQLite database without adding any ORM. This is the first post in a 2 part series where we’ll explore what this experience is like using only the driver and utilities made available in the Wrangler CLI. Introduction If you’re unfamiliar with Cloudflare D1 - it’s a distributed SQLite database for the Cloudflare Workers platform. Workers are lightweight serverless functions/compute distributed across a global network. The platform includes services and API’s like D1 that provide extended capabilities to your Workers. At the time of this writing, there are only two ways to interact with a D1 database that I’m aware of. * From a Worker using the D1 Client API * D1 HTTP API In this 2 part series, we will create a simple Workers project and use the D1 Client API to build out our queries. Getting Started For this tutorial, we’ll create a simple Cloudflare Worker project and treat it like a simple node script/http server to do our experiments. The first step is initializing a new Cloudflare Worker and D1 database: ` ` We need to take our binding and add it to our project wrangler.toml configuration. Once our binding is added we can re-generate the types for our Worker project. ` We now have our DB binding added to our project Env types. Let’s add a simple query to our worker script to make sure our database is setup and working: ` Start the development server with npm run dev and access the server at http://localhost:8787 . When the page loads we should see a successful result {"1 + 1":2} . We now have a working SQLite database available. Creating a schema Since we’re not using an ORM with some kind of DSL to define a schema for our database, we’re going to do things the old fashioned way. “Schema” will just refer to the data model that we create for our database. We’ll define it using SQL and the D1 migrations utility. Let’s create a migration to define our initial schema: ` For our demo purposes we will build out a simple blog application database. It includes posts, authors, and tags to include some relational data. We need to write the SQL in our migration to create all the tables and columns that we need: ` The SQL above defines our tables, columns, and their relations with foreign keys. It also includes a join table for posts and tags to represent a many-to-many relationship. If you don’t have a lot of experience writing SQL queries it might look a little bit intimidating at first. Once you take some time to learn it it’s actually pretty nice. DataLemur is a pretty great resource for learning SQL. If you need help with a specific query, Copilot and GPT are quite good at generating SQL queries. Just make sure you take some time to try to understand them and check for any potential issues. After completing a migration script it needs to be applied: ` I added the --local flag so that we’re working against a local database for now. Typing our Schema One of the downsides of our ORMless approach is we don’t get TypeScript types out of the box. In a smaller project, I think the easiest approach is just to manage your own types. It’s not hard, you can even have GPT help if you want. If managing type definitions for your schema is not acceptable for your project or use case you can look for a code generation tool or switch to an ORM / toolset that provides types. For this example I created some basic types to map to our schema so that we can get help from the lsp when working with our queries. ` Seeding development data Outside of our migrations, we can write SQL scripts and execute them against our D1 SQLite database using wrangler. To start we can create a simple seeds/dev.sql script to load some initial development seed data into our local database. Another example might be a reset.sql that drops all of our tables so we can easily reset our database during development as we rework the schema or run other experiments. Since our database is using auto incrementing integer ID’s, we can know up front what the ID’s for the rows we are creating are since our database is initially empty. This can be a bit tricky if you’re using randomly generated ID’s. In that case you would probably want to write a script that can collect ID’s of newly created records and use them for creating related records. Here we are just passing the integer ID directly in our SQL script. As an example, we know up front that the author Alice Smith will have the id 1, Bob Johnson 2, and so on. Post_tags looks a little bit crazy since it’s just a join table. Each row is just a post_id and a tag_id. (1, 1), (1 2), etc. Here’s the code for a dev seed script: ` Here’s the code for a reset script - it’s important to remember to drop the migrations table in your reset so you can apply your migrations. ` Using the wrangler CLI we can execute our script files against our local development and remote d1 database instances. Since we have already applied our migrations to our local database, we can use our dev.sql seed script to load some data into our db. ` The Wrangler output is pretty helpful - it lets us know to add the --remote flag to run against our remote instance. We can also use execute to run commands against our database. Lets run a select to look at the data added to our posts table. ` This command should output a table showing the columns of our db and the 7 rows we added from the dev seed script. Summary Using wrangler and the Cloudflare D1 platform we’ve already gotten pretty far without an ORM or any other additional tooling. We have a simple but effective migrations system in place and some initial scripts for easily seeding and resetting our databases. There are also some other really great things built-in to the D1 platform like time travel and backups. I definitely recommend at least skimming through the documentation at some point. In the next post we will start interacting with our database and sample data using the D1 Client API....

The simplicity of deploying an MCP server on Vercel cover image

The simplicity of deploying an MCP server on Vercel

The current Model Context Protocol (MCP) spec is shifting developers toward lightweight, stateless servers that serve as tool providers for LLM agents. These MCP servers communicate over HTTP, with OAuth handled clientside. Vercel’s infrastructure makes it easy to iterate quickly and ship agentic AI tools without overhead. Example of Lightweight MCP Server Design At This Dot Labs, we built an MCP server that leverages the DocuSign Navigator API. The tools, like `get_agreements`, make a request to the DocuSign API to fetch data and then respond in an LLM-friendly way. ` Before the MCP can request anything, it needs to guide the client on how to kick off OAuth. This involves providing some MCP spec metadata API endpoints that include necessary information about where to obtain authorization tokens and what resources it can access. By understanding these details, the client can seamlessly initiate the OAuth process, ensuring secure and efficient data access. The Oauth flow begins when the user's LLM client makes a request without a valid auth token. In this case they’ll get a 401 response from our server with a WWW-Authenticate header, and then the client will leverage the metadata we exposed to discover the authorization server. Next, the OAuth flow kicks off directly with Docusign as directed by the metadata. Once the client has the token, it passes it in the Authorization header for tool requests to the API. ` This minimal set of API routes enables me to fetch Docusign Navigator data using natural language in my agent chat interface. Deployment Options I deployed this MCP server two different ways: as a Fastify backend and then by Vercel functions. Seeing how simple my Fastify MCP server was, and not really having a plan for deployment yet, I was eager to rewrite it for Vercel. The case for Vercel: * My own familiarity with Next.js API deployment * Fit for architecture * The extremely simple deployment process * Deploy previews (the eternal Vercel customer conversion feature, IMO) Previews of unfamiliar territory Did you know that the MCP spec doesn’t “just work” for use as ChatGPT tooling? Neither did I, and I had to experiment to prove out requirements that I was unfamiliar with. Part of moving fast for me was just deploying Vercel previews right out of the CLI so I could test my API as a Connector in ChatGPT. This was a great workflow for me, and invaluable for the team in code review. Stuff I’m Not Worried About Vercel’s mcp-handler package made setup effortless by abstracting away some of the complexity of implementing the MCP server. It gives you a drop-in way to define tools, setup https-streaming, and handle Oauth. By building on Vercel’s ecosystem, I can focus entirely on shipping my product without worrying about deployment, scaling, or server management. Everything just works. ` A Brief Case for MCP on Next.js Building an API without Next.js on Vercel is straightforward. Though, I’d be happy deploying this as a Next.js app, with the frontend features serving as the documentation, or the tools being a part of your website's agentic capabilities. Overall, this lowers the barrier to building any MCP you want for yourself, and I think that’s cool. Conclusion I'll avoid quoting Vercel documentation in this post. AI tooling is a critical component of this natural language UI, and we just want to ship. I declare Vercel is excellent for stateless MCP servers served over http....

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co