mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-10-30 07:26:00 -04:00
publish tailwind post with new image diff component
This commit is contained in:
@@ -53,7 +53,9 @@
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans--font-feature-settings: "rlig" 1, "calt" 0;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-mono--font-feature-settings: "liga" 0;
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@@ -154,9 +156,6 @@
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-synthesis-weight: none;
|
||||
font-variant-ligatures: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -69,9 +69,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
<div className="mx-auto w-full max-w-4xl px-5">
|
||||
<Header className="mt-4 mb-6 w-full" />
|
||||
|
||||
<main id={SKIP_NAV_ID} tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
<main id={SKIP_NAV_ID}>{children}</main>
|
||||
|
||||
<Footer className="my-6 w-full" />
|
||||
</div>
|
||||
|
||||
@@ -119,6 +119,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
<span>Improve This Post</span>
|
||||
</Link>
|
||||
|
||||
{env.NEXT_PUBLIC_ENV === "production" && (
|
||||
<div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap">
|
||||
<EyeIcon className="inline size-4 shrink-0" />
|
||||
<Suspense
|
||||
@@ -129,6 +130,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="mt-2 mb-3 text-3xl/10 font-bold md:text-4xl/12">
|
||||
@@ -141,11 +143,15 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
|
||||
|
||||
<MDXContent />
|
||||
|
||||
{!frontmatter!.noComments && (
|
||||
<div id="comments" className="border-ring mt-8 min-h-36 border-t-2 pt-8">
|
||||
{env.NEXT_PUBLIC_ENV === "production" && (
|
||||
<div id="comments" className="mt-8 min-h-36 border-t-2 pt-8">
|
||||
{!frontmatter!.noComments ? (
|
||||
<Suspense fallback={<Loading boxes={3} width={40} className="mx-auto my-8 block" />}>
|
||||
<Comments title={frontmatter!.title} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="text-foreground/85 text-center font-medium">Comments are disabled for this post.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
133
components/image-diff.tsx
Normal file
133
components/image-diff.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/* eslint-disable jsx-a11y/alt-text, @next/next/no-img-element */
|
||||
"use client";
|
||||
|
||||
import { ReactElement, Children, useState, useRef, useEffect } from "react";
|
||||
import { getImageProps } from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsLeftRightIcon } from "lucide-react";
|
||||
|
||||
const ImageDiff = ({ children, className }: { children: ReactElement[]; className?: string }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [sliderPosition, setSliderPosition] = useState(50);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Add event listeners to handle dragging outside the component
|
||||
useEffect(() => {
|
||||
const handleMouseUpGlobal = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseMoveGlobal = (e: MouseEvent) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
setSliderPosition(percentage);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUpGlobal);
|
||||
document.addEventListener("mousemove", handleMouseMoveGlobal);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseup", handleMouseUpGlobal);
|
||||
document.removeEventListener("mousemove", handleMouseMoveGlobal);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// Extract the two image children
|
||||
const childrenArray = Children.toArray(children);
|
||||
if (childrenArray.length !== 2) {
|
||||
console.error("ImageDiff must have exactly two children (before and after images)");
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const beforeImageProps = getImageProps(children[0].props as any).props;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const afterImageProps = getImageProps(children[1].props as any).props;
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
setSliderPosition(percentage);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
setSliderPosition(percentage);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const step = 5;
|
||||
if (e.key === "ArrowLeft") {
|
||||
setSliderPosition((prev) => Math.max(0, prev - step));
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
setSliderPosition((prev) => Math.min(100, prev + step));
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchMove={handleTouchMove}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("relative isolate w-full max-w-full overflow-hidden select-none", className)}
|
||||
role="slider"
|
||||
aria-label="Image comparison slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={sliderPosition}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* After image (full width, underneath) */}
|
||||
<div className="h-full w-full">
|
||||
<img {...afterImageProps} className="h-full w-full object-cover object-top-left" />
|
||||
</div>
|
||||
|
||||
{/* Before image (clipped with width based on slider position) */}
|
||||
<div className="absolute top-0 left-0 h-full overflow-hidden" style={{ width: `${sliderPosition}%` }}>
|
||||
<img {...beforeImageProps} className="h-full w-full object-cover object-top-left" />
|
||||
</div>
|
||||
|
||||
{/* Divider line */}
|
||||
<div
|
||||
className="bg-muted-foreground absolute top-0 bottom-0 w-1 -translate-x-1/2 drop-shadow-md"
|
||||
style={{ left: `${sliderPosition}%` }}
|
||||
/>
|
||||
|
||||
{/* Slider handle */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
onTouchEnd={handleMouseUp}
|
||||
className="bg-muted absolute top-1/2 flex h-10 w-10 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none items-center justify-center rounded-full border-2 drop-shadow-md"
|
||||
style={{ left: `${sliderPosition}%` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronsLeftRightIcon className="text-foreground/70 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageDiff;
|
||||
11
lib/env.ts
11
lib/env.ts
@@ -92,6 +92,16 @@ export const env = createEnv({
|
||||
`http://localhost:${process.env.PORT || 3000}`
|
||||
),
|
||||
|
||||
/**
|
||||
* Optional. Set this to override the best guess as to the environment the site is running in.
|
||||
*/
|
||||
NEXT_PUBLIC_ENV: v.fallback(v.picklist(["production", "development"]), () =>
|
||||
(process.env.VERCEL && process.env.VERCEL_ENV === "production") ||
|
||||
(process.env.NETLIFY && process.env.CONTEXT === "production")
|
||||
? "production"
|
||||
: "development"
|
||||
),
|
||||
|
||||
/**
|
||||
* Optional. Enables comments on blog posts via GitHub discussions.
|
||||
*
|
||||
@@ -146,6 +156,7 @@ export const env = createEnv({
|
||||
},
|
||||
experimental__runtimeEnv: {
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_ENV: process.env.NEXT_PUBLIC_ENV,
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
|
||||
NEXT_PUBLIC_GISCUS_REPO_ID: process.env.NEXT_PUBLIC_GISCUS_REPO_ID,
|
||||
NEXT_PUBLIC_GITHUB_REPO: process.env.NEXT_PUBLIC_GITHUB_REPO,
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "@/components/link";
|
||||
import CodeBlock from "@/components/code-block";
|
||||
import HeadingAnchor from "@/components/heading-anchor";
|
||||
import Video from "@/components/video";
|
||||
import ImageDiff from "./components/image-diff";
|
||||
import Tweet from "@/components/third-party/tweet";
|
||||
import YouTube from "@/components/third-party/youtube";
|
||||
import Gist from "@/components/third-party/gist";
|
||||
@@ -115,8 +116,9 @@ export const useMDXComponents = (components: MDXComponents): MDXComponents => {
|
||||
<hr className={cn("mx-auto my-6 w-11/12 border-t-2 [&+*]:mt-0", className)} {...rest} />
|
||||
),
|
||||
|
||||
// third-party embeds:
|
||||
// react components and embeds:
|
||||
Video,
|
||||
ImageDiff,
|
||||
Tweet,
|
||||
YouTube,
|
||||
Gist,
|
||||
|
||||
BIN
notes/tailwind-hater/after.png
Normal file
BIN
notes/tailwind-hater/after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
BIN
notes/tailwind-hater/before.png
Normal file
BIN
notes/tailwind-hater/before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 398 KiB |
46
notes/tailwind-hater/index.mdx
Normal file
46
notes/tailwind-hater/index.mdx
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: I Was Wrong About Tailwind CSS
|
||||
date: "2025-05-08 14:33:10-0400"
|
||||
description: I never thought I'd say this, but Tailwind CSS made my frontend code cleaner, simpler, and even enjoyable.
|
||||
tags:
|
||||
- CSS
|
||||
- Tailwind
|
||||
- Frontend
|
||||
- React
|
||||
- AI
|
||||
- Meta
|
||||
- Confession
|
||||
image: ./tailwind.png
|
||||
---
|
||||
|
||||
I have a confession. I've **hated** [Tailwind CSS](https://tailwindcss.com/). For years.
|
||||
|
||||
Every time a Tailwind ["hit piece"](https://dev.to/kerryboyko/tailwindcss-adds-complexity-does-nothing-3hpn) went viral, I quietly enjoyed the warm, fuzzy feeling of confirmation bias.
|
||||
|
||||
I've also never been the biggest advocate of anything AI (beyond providing a smarter autocomplete), but that's a post for another day. I do, however, appreciate what companies like [Vercel](https://vercel.com/home) are doing with tools like [**v0**](https://v0.dev/), or [Stackblitz](https://stackblitz.com/) with [**Bolt.new**](https://bolt.new/) (or especially new startups like [**Lovable**](https://lovable.dev/)). They're embarrassingly bad at generating anything close to full-stack right now, but are incredible at prototyping small drop-in React components like a [copy button](https://v0.dev/chat/custom-copy-button-6giyaNd8fmn) or even [beautiful charts](https://v0.dev/chat/next-js-charts-eygG8PAxk8z).
|
||||
|
||||
So what's the conundrum? These tools _love_ Tailwind (and its offspring — [shadcn/ui](https://ui.shadcn.com/) being the poster child). And I wanted to figure out why.
|
||||
|
||||
<Tweet id="1842643838281961934" />
|
||||
|
||||
As I usually do with any piece of software I want to learn more about, I used [this website](https://github.com/jakejarvis/jarv.is) as a testing ground. I started with a few one-liner components, like my styled [`<Blockquote>`](https://github.com/jakejarvis/jarv.is/tree/v6/components/Blockquote) for Markdown content. I quickly stumbled upon dozens of CSS to Tailwind "conversion" tools, but just as quickly realized that actually digging into Tailwind would be much easier. This lead to my first discovery:
|
||||
|
||||
🏆 **[Tailwind's documentation](https://tailwindcss.com/docs/styling-with-utility-classes) is some of the best I've ever seen.**
|
||||
|
||||
Seriously, it's so good that even if you're not using Tailwind, their docs clearly explain and visually demonstrate all of the most [nonsensical](https://tailwindcss.com/docs/flex-basis) CSS "features" I've always struggled to master.
|
||||
|
||||
After converting a few more components, I started to feel better and better each time I deleted a [`.module.css`](https://github.com/jakejarvis/jarv.is/blob/v6/components/Blockquote/Blockquote.module.css) file. What I thought would make my code infinitely more complicated and messy was actually making it simpler and cleaner. Each component no longer needed its own folder. Colors and spacing were becoming more consistent. Pixels I struggled to line up previously were now coincidentally falling in line. Best of all, **I didn't have to name anything anymore!** 🎉
|
||||
|
||||
<ImageDiff>
|
||||

|
||||
</ImageDiff>
|
||||
|
||||
Don't get me wrong, I still think the syntax Tailwind forces you to write is an abomination. **But honestly, so was my CSS.**
|
||||
|
||||
Maybe that's on me, or maybe not, but my primary reason to hate on Tailwind for years — _"it makes my HTML/JSX ugly and design doesn't belong sprinkled throughout a markup language"_ — just flew out the window either way. Sure, I tried to make my CSS consistent and logical, making tons of variables for colors and sizes and border radii. But that wasn't nearly as comforting as being certain that `w-12` will **always** be twice the width of `w-6` no matter how badly I mess things up.
|
||||
|
||||
And on top of all of the AI tools mentioned above being Tailwind experts, the [IDE support](https://tailwindcss.com/docs/editor-setup) is also excellent. One click to install the official [IntelliSense extension](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) for VS Code, and suddenly everywhere I wrote <span style={{ color: "#38bdf8" }}>`text-sky-400`</span> throughout my code had a lovely little light blue square next to it. The official [Prettier extension](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) ensures the order of class names doesn't cause unexpected specificity problems from a rule four layers up overriding a rule you thought you were currently looking at — historically my biggest painpoint of CSS by far.
|
||||
|
||||
All of these tools together actually made the [process](https://github.com/jakejarvis/jarv.is/pull/2387) of revamping this site oddly fun. It shined a spotlight on a lot of issues I had no idea were there — especially by forcing me to think ["mobile-first"](https://tailwindcss.com/docs/responsive-design#working-mobile-first) — and gave me an opportunity to put a new coat of paint on a design I haven't made major changes to [since my last blog post](/notes/hugo-to-nextjs)...three years ago.
|
||||
|
||||
So, if you're a closeted Tailwind hater like I was, try it out. I don't think I'll ever love Tailwind, to be honest. But I certainly like it a lot more than I ever liked CSS.
|
||||
BIN
notes/tailwind-hater/tailwind.png
Normal file
BIN
notes/tailwind-hater/tailwind.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
@@ -84,7 +84,7 @@
|
||||
"@jakejarvis/eslint-config": "^4.0.7",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^22.15.16",
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/react": "19.1.3",
|
||||
"@types/react-dom": "19.1.3",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-af1b7da-20250417",
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -202,8 +202,8 @@ importers:
|
||||
specifier: ^2.0.13
|
||||
version: 2.0.13
|
||||
'@types/node':
|
||||
specifier: ^22.15.16
|
||||
version: 22.15.16
|
||||
specifier: ^22.15.17
|
||||
version: 22.15.17
|
||||
'@types/react':
|
||||
specifier: 19.1.3
|
||||
version: 19.1.3
|
||||
@@ -1230,8 +1230,8 @@ packages:
|
||||
'@types/nlcst@2.0.3':
|
||||
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
|
||||
|
||||
'@types/node@22.15.16':
|
||||
resolution: {integrity: sha512-3pr+KjwpVujqWqOKT8mNR+rd09FqhBLwg+5L/4t0cNYBzm/yEiYGCxWttjaPBsLtAo+WFNoXzGJfolM1JuRXoA==}
|
||||
'@types/node@22.15.17':
|
||||
resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==}
|
||||
|
||||
'@types/react-dom@19.1.3':
|
||||
resolution: {integrity: sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==}
|
||||
@@ -5200,7 +5200,7 @@ snapshots:
|
||||
|
||||
'@types/concat-stream@2.0.3':
|
||||
dependencies:
|
||||
'@types/node': 22.15.16
|
||||
'@types/node': 22.15.17
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
@@ -5236,7 +5236,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/node@22.15.16':
|
||||
'@types/node@22.15.17':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
@@ -8770,7 +8770,7 @@ snapshots:
|
||||
'@types/concat-stream': 2.0.3
|
||||
'@types/debug': 4.1.12
|
||||
'@types/is-empty': 1.2.3
|
||||
'@types/node': 22.15.16
|
||||
'@types/node': 22.15.17
|
||||
'@types/unist': 3.0.3
|
||||
concat-stream: 2.0.0
|
||||
debug: 4.4.0
|
||||
|
||||
Reference in New Issue
Block a user