Skip to content

Instantly share code, notes, and snippets.

@Neophen
Last active June 17, 2025 18:24
Show Gist options
  • Select an option

  • Save Neophen/2f512ace1e7182e5346076333e4a0fdc to your computer and use it in GitHub Desktop.

Select an option

Save Neophen/2f512ace1e7182e5346076333e4a0fdc to your computer and use it in GitHub Desktop.
Better states, plugin for Tailwind v4

Better hover/active states, Tailwind CSS

Preview:

example_states.mov

Tailwind Play link:

https://play.tailwindcss.com/Aj7D2OHw20

Use case: A card with main action and secondary action

example_card_with_actions.mov

Tailwind Play link:

https://play.tailwindcss.com/6oReKHMiQG

HTML if the link is broken

<div class="grid gap-6 bg-slate-100 p-4">
  <h1>Use case: card with main link and other actions</h1>
  <div class="rounded-md border bg-white p-4">
    <div class="group relative flex items-center justify-between rounded-md border p-4 hovered-action:bg-blue-100 hovered-action:ring-2 hovered-action:ring-black pressed-action:bg-blue-600">
      <p class="font-bold group-hovered-action:text-blue-600 group-pressed-action:text-white">Mykolas Mankevicius</p>
      <div class="flex gap-4">
        <button type="button" class="block rounded border bg-white px-2 py-1 hovered:bg-red-50 hovered:text-red-600 z-10">Remove</button>
        <button type="button" class="action block rounded border bg-slate-600 px-2 py-1 text-white before:absolute before:inset-0 hovered:bg-slate-900">View Profile</button>
      </div>
    </div>
  </div>
</div>
<div class="grid gap-6 bg-slate-100 p-4">
<div class="grid gap-4 rounded-md border bg-white p-4 pressed-action:text-white">
<div class="group peer grid gap-2 rounded-md border p-4 hovered-action:bg-blue-100 pressed-action:bg-blue-500">
<h2 class="font-bold">group - peer - group-hovered-action</h2>
<button type="button" class="action block rounded border bg-amber-200 px-2 py-1 hovered:bg-amber-400">Action</button>
<div class="rounded bg-teal-100 p-2 group-hovered-action:bg-teal-500 group-pressed-action:bg-teal-800">I change state when <span class="inline-block rounded p-1 font-bold">Group &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-red-500 p-2 text-white">I don't change</div>
</div>
<div class="rounded bg-green-100 p-2 peer-hovered-action:bg-green-500 peer-pressed-action:bg-green-800">I change state whn <span class="inline-block rounded p-1 font-bold">Peer &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-indigo-100 p-2 peer-hovered:bg-indigo-500 peer-pressed:bg-indigo-800">I change states when anything on <span class="inline-block rounded p-1 font-bold">Peer</span> is hovered/pressed</div>
</div>
<div class="grid gap-4 rounded-md border bg-white p-4 pressed-action:text-white">
<div class="group/card peer/card grid gap-2 rounded-md border p-4 hovered-action:bg-blue-100 pressed-action:bg-blue-500">
<h2 class="font-bold">group/card - peer/card - group-hovered-action/card</h2>
<button type="button" class="action block rounded border bg-amber-200 px-2 py-1 hovered:bg-amber-400">Action</button>
<div class="rounded bg-teal-100 p-2 group-hovered-action/card:bg-teal-500 group-pressed-action/card:bg-teal-800">I change state when <span class="inline-block rounded p-1 font-bold">Group &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-red-500 p-2 text-white">I don't change</div>
</div>
<div class="rounded bg-green-100 p-2 peer-hovered-action/card:bg-green-500 peer-pressed-action/card:bg-green-800">I change state whn <span class="inline-block rounded p-1 font-bold">Peer &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-indigo-100 p-2 peer-hovered/card:bg-indigo-500 peer-pressed/card:bg-indigo-800">I change states when anything on <span class="inline-block rounded p-1 font-bold">Peer</span> is hovered/pressed</div>
</div>
<div class="grid gap-4 rounded-md border bg-white p-4 pressed-[.selector]:text-white">
<div class="group peer grid gap-2 rounded-md border p-4 hovered-[.selector]:bg-blue-100 pressed-[.selector]:bg-blue-500">
<h2 class="font-bold">group - peer - group-hovered-[.selector]</h2>
<button type="button" class="selector block rounded border bg-amber-200 px-2 py-1 hovered:bg-amber-400">Action</button>
<div class="rounded bg-teal-100 p-2 group-hovered-[.selector]:bg-teal-500 group-pressed-[.selector]:bg-teal-800">I change state when <span class="inline-block rounded p-1 font-bold">Group &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-red-500 p-2 text-white">I don't change</div>
</div>
<div class="rounded bg-green-100 p-2 peer-hovered-[.selector]:bg-green-500 peer-pressed-[.selector]:bg-green-800">I change state whn <span class="inline-block rounded p-1 font-bold">Peer &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-indigo-100 p-2 peer-hovered:bg-indigo-500 peer-pressed:bg-indigo-800">I change states when anything on <span class="inline-block rounded p-1 font-bold">Peer</span> is hovered/pressed</div>
</div>
<div class="grid gap-4 rounded-md border bg-white p-4 pressed-[.selector]:text-white">
<div class="group/card peer/card grid gap-2 rounded-md border p-4 hovered-[.selector]:bg-blue-100 pressed-[.selector]:bg-blue-500">
<h2 class="font-bold">group/card - peer/card - group-hovered-[.selector]/card</h2>
<button type="button" class="selector block rounded border bg-amber-200 px-2 py-1 hovered:bg-amber-400">Action</button>
<div class="rounded bg-teal-100 p-2 group-hovered-[.selector]/card:bg-teal-500 group-pressed-[.selector]/card:bg-teal-800">I change state when <span class="inline-block rounded p-1 font-bold">Group &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-red-500 p-2 text-white">I don't change</div>
</div>
<div class="rounded bg-green-100 p-2 peer-hovered-[.selector]/card:bg-green-500 peer-pressed-[.selector]/card:bg-green-800">I change state whn <span class="inline-block rounded p-1 font-bold">Peer &gt; Action</span> is hovered/pressed</div>
<div class="rounded bg-indigo-100 p-2 peer-hovered/card:bg-indigo-500 peer-pressed/card:bg-indigo-800">I change states when anything on <span class="inline-block rounded p-1 font-bold">Peer</span> is hovered/pressed</div>
</div>
</div>
import plugin from "tailwindcss/plugin";
type MaybeString = string | null | undefined;
type UtilType = "group" | "peer";
// Main plugin
export default plugin(function ({ matchVariant, theme, e }) {
const values = {
values: {
DEFAULT: "",
...theme("betterStates", {}),
},
};
const selector = (state: string, prefix: MaybeString) => {
return `${prefix ?? "&"}:${state}:not(:disabled)`;
};
const maybeWrap = (state: string, prefix = "&", value: MaybeString) => {
return ["", null, undefined].includes(value)
? selector(state, prefix)
: `${prefix}:has(${selector(state, value)})`;
};
const merge = (
type: UtilType,
value: MaybeString,
modifier: string | null,
callback: (value: MaybeString, prefix: string) => string[]
) => {
const typeClass = modifier ? `.${type}\\/${e(modifier)}` : `.${type}`;
const append = type === "group" ? " &" : " ~ &";
return callback(value, `:merge(${typeClass})`).map((x) => `${x}${append}`);
};
// Hovered ________________________________________________________________________
const hovered = (value: MaybeString = null, prefix = "&") => [
maybeWrap("focus-visible", prefix, value),
maybeWrap("hover", prefix, value),
maybeWrap("has(:focus-visible)", prefix, value),
];
matchVariant("hovered", (value) => hovered(value), values);
matchVariant(
"group-hovered",
(value, { modifier }) => merge("group", value, modifier, hovered),
values
);
matchVariant(
"peer-hovered",
(value, { modifier }) => merge("peer", value, modifier, hovered),
values
);
// Pressed ________________________________________________________________________
const pressed = (value: MaybeString = null, prefix = "&") => [
maybeWrap("active", prefix, value),
maybeWrap("has(:active)", prefix, value),
`@media (hover: none) { ${maybeWrap('hover', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('focus-visible', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('has(:focus-visible)', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('focus', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('has(:focus)', prefix, value)} & }`
];
matchVariant("pressed", (value) => pressed(value), values);
matchVariant(
"group-pressed",
(value, { modifier }) => merge("group", value, modifier, pressed),
values
);
matchVariant(
"peer-pressed",
(value, { modifier }) => merge("peer", value, modifier, pressed),
values
);
});
const plugin = require('tailwindcss/plugin')
/** @type {import('tailwindcss').Config} */
export default {
theme: {
extend: {
betterStates: {
action: '.action',
},
},
},
plugins: [
plugin(function ({ matchVariant, theme, e }) {
const values = {
values: {
DEFAULT: '',
...theme('betterStates', {}),
},
}
const selector = (state, prefix) => {
return `${prefix ?? '&'}:${state}:not(:disabled)`
}
const maybeWrap = (state, prefix = '&', value) => {
return ['', null, undefined].includes(value)
? selector(state, prefix)
: `${prefix}:has(${selector(state, value)})`
}
const merge = (type, value, modifier, callback) => {
const typeClass = modifier ? `.${type}\\/${e(modifier)}` : `.${type}`
const append = type === 'group' ? ' &' : ' ~ &'
return callback(value, `:merge(${typeClass})`).map((x) => `${x}${append}`)
}
// Hovered ________________________________________________________________________
const hovered = (value = null, prefix = '&') => [
maybeWrap('focus-visible', prefix, value),
maybeWrap('hover', prefix, value),
maybeWrap('has(:focus-visible)', prefix, value),
]
matchVariant('hovered', (value) => hovered(value), values)
matchVariant(
'group-hovered',
(value, { modifier }) => merge('group', value, modifier, hovered),
values,
)
matchVariant(
'peer-hovered',
(value, { modifier }) => merge('peer', value, modifier, hovered),
values,
)
// Pressed ________________________________________________________________________
const pressed = (value = null, prefix = '&') => [
maybeWrap('active', prefix, value),
maybeWrap('has(:active)', prefix, value),
`@media (hover: none) { ${maybeWrap('hover', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('focus-visible', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('has(:focus-visible)', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('focus', prefix, value)} & }`,
`@media (hover: none) { ${maybeWrap('has(:focus)', prefix, value)} & }`
]
matchVariant('pressed', (value) => pressed(value), values)
matchVariant(
'group-pressed',
(value, { modifier }) => merge('group', value, modifier, pressed),
values,
)
matchVariant(
'peer-pressed',
(value, { modifier }) => merge('peer', value, modifier, pressed),
values,
)
}),
],
}
/** @type {import('tailwindcss').Config} */
export const theme = {
extend: {
betterStates: {
action: ".action",
},
};
export const plugins = [
require("./plugin-better-state"),
];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment