Skip to content

Instantly share code, notes, and snippets.

@Neophen
Last active June 17, 2025 18:24
Show Gist options
  • Save Neophen/2f512ace1e7182e5346076333e4a0fdc to your computer and use it in GitHub Desktop.
Save Neophen/2f512ace1e7182e5346076333e4a0fdc to your computer and use it in GitHub Desktop.

Revisions

  1. Neophen revised this gist Jun 17, 2025. 5 changed files with 49 additions and 131 deletions.
    13 changes: 5 additions & 8 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -6,27 +6,24 @@ https://gist.github.com/user-attachments/assets/4af1f288-6c64-41e0-8765-56fb8e5d

    ## Tailwind Play link:

    [https://play.tailwindcss.com/Aj7D2OHw20](https://play.tailwindcss.com/Aj7D2OHw20)
    [https://play.tailwindcss.com/7HbSJlaQGT](https://play.tailwindcss.com/7HbSJlaQGT)

    # Use case: A card with main action and secondary action

    https://gist.github.com/user-attachments/assets/420b6008-965c-424a-a610-6544a1774804

    ## Tailwind Play link:

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

    ## HTML if the link is broken

    ```html
    <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="group/item 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/item:text-blue-600 group-pressed-action/item: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="peer z-10 block rounded border bg-white px-2 py-1 hovered:bg-red-50 hovered:text-red-900 pressed:bg-red-200">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 class="peer-hovered:bg-amber-50 peer-pressed:bg-amber-100">peer-pressed = bg-amber-100</div>
    </div>
    </div>
    </div>
    35 changes: 35 additions & 0 deletions app.css
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,35 @@
    @import "tailwindcss";

    @custom-variant hovered {
    @media (hover: hover) {
    &:hover,
    &:focus-visible,
    &:has(:focus-visible) {
    @slot;
    }
    }
    }

    @custom-variant hovered-action {
    @media (hover: hover) {
    &:has(.action:hover),
    &:has(.action:focus-visible) {
    @slot;
    }
    }
    }

    @custom-variant pressed {
    @media (hover: hover) {
    &:active {
    @slot;
    }
    }
    }
    @custom-variant pressed-action {
    @media (hover: hover) {
    &:has(.action:active) {
    @slot;
    }
    }
    }
    47 changes: 9 additions & 38 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -1,42 +1,13 @@
    <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>
    <h1>Use case: card with main link and other actions</h1>
    <div class="rounded-md border bg-white p-4">
    <div class="group/item 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/item:text-blue-600 group-pressed-action/item:text-white">Mykolas Mankevicius</p>
    <div class="flex gap-4">
    <button type="button" class="peer z-10 block rounded border bg-white px-2 py-1 hovered:bg-red-50 hovered:text-red-900 pressed:bg-red-200">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 class="peer-hovered:bg-amber-50 peer-pressed:bg-amber-100">peer-pressed = bg-amber-100</div>
    </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>
    73 changes: 0 additions & 73 deletions plugin-better-state.ts
    Original file line number Diff line number Diff line change
    @@ -1,73 +0,0 @@
    import plugin from 'tailwindcss/plugin'

    type MaybeString = string | null | undefined
    type UtilType = 'group' | 'peer'

    // Example
    // <div class="group relative hovered-action:bg-blue-100 pressed-action:bg-blue-600">
    // <p class="group-hovered-action:text-blue-600 group-pressed-action:text-white">Mykolas Mankevicius</p>
    // <button class="hovered:bg-red-50 hovered:text-red-600 z-10">Remove</button>
    // <button data-action class="before:absolute before:inset-0 hovered:bg-slate-100">View Profile</button>
    // </div>

    // Main plugin
    export default plugin((api) => {
    const betterStates = api.theme('betterStates', {})

    const values = {
    values: {
    DEFAULT: '',
    ...betterStates
    }
    }

    const selector = (state: string, prefix: MaybeString) => {
    return `${prefix ?? '&'}:${state}:not(:disabled)`
    }

    const maybeWrap = (state: string, prefix: string, 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, merge: boolean) => string[]
    ) => {
    const typeClass = modifier ? `.${type}\\/${api.e(modifier)}` : `.${type}`
    const append = type === 'group' ? ' &' : ' ~ &'
    return callback(value, `:merge(${typeClass})`, true).map((x) => `${x}${append}`)
    }

    // Hovered ________________________________________________________________________
    const hovered = (value: MaybeString = null, prefix = '&', merge = false) => {
    const postfix = merge ? ' & ' : ''
    return [
    `@media (hover: hover) and (pointer: fine) { ${maybeWrap('hover', prefix, value)}${postfix}}`,
    `@media (hover: hover) and (pointer: fine) { ${maybeWrap('focus-visible', prefix, value)}${postfix}}`,
    `@media (hover: hover) and (pointer: fine) { ${maybeWrap('has(:focus-visible)', prefix, value)}${postfix}}`
    ]
    }

    api.matchVariant('hovered', (value) => hovered(value), values)
    api.matchVariant('group-hovered', (value, { modifier }) => merge('group', value, modifier, hovered), values)
    api.matchVariant('peer-hovered', (value, { modifier }) => merge('peer', value, modifier, hovered), values)

    // Pressed ________________________________________________________________________
    const pressed = (value: MaybeString = null, prefix = '&', merge = false) => {
    const postfix = merge ? ' & ' : ''
    return [
    maybeWrap('active', prefix, value),
    maybeWrap('has(:active)', prefix, value),
    // Wrap the entire selector in the media query, not just the state
    `@media (hover: none) or (pointer: coarse) { ${maybeWrap('hover', prefix, value)}${postfix}}`,
    `@media (hover: none) or (pointer: coarse) { ${maybeWrap('focus-visible', prefix, value)}${postfix}}`,
    `@media (hover: none) or (pointer: coarse) { ${maybeWrap('has(:focus-visible)', prefix, value)}${postfix}}`
    ]
    }

    api.matchVariant('pressed', (value) => pressed(value), values)
    api.matchVariant('group-pressed', (value, { modifier }) => merge('group', value, modifier, pressed), values)
    api.matchVariant('peer-pressed', (value, { modifier }) => merge('peer', value, modifier, pressed), values)
    })
    12 changes: 0 additions & 12 deletions tailwind.config.ts
    Original file line number Diff line number Diff line change
    @@ -1,12 +0,0 @@
    /** @type {import('tailwindcss').Config} */

    export const theme = {
    extend: {
    betterStates: {
    action: ".action",
    },
    };

    export const plugins = [
    require("./plugin-better-state"),
    ];
  2. Neophen revised this gist Dec 20, 2024. 2 changed files with 63 additions and 147 deletions.
    130 changes: 63 additions & 67 deletions plugin-better-state.ts
    Original file line number Diff line number Diff line change
    @@ -1,77 +1,73 @@
    import plugin from "tailwindcss/plugin";
    import plugin from 'tailwindcss/plugin'

    type MaybeString = string | null | undefined;
    type UtilType = "group" | "peer";
    type MaybeString = string | null | undefined
    type UtilType = 'group' | 'peer'

    // Example
    // <div class="group relative hovered-action:bg-blue-100 pressed-action:bg-blue-600">
    // <p class="group-hovered-action:text-blue-600 group-pressed-action:text-white">Mykolas Mankevicius</p>
    // <button class="hovered:bg-red-50 hovered:text-red-600 z-10">Remove</button>
    // <button data-action class="before:absolute before:inset-0 hovered:bg-slate-100">View Profile</button>
    // </div>

    // Main plugin
    export default plugin(function ({ matchVariant, theme, e }) {
    const values = {
    values: {
    DEFAULT: "",
    ...theme("betterStates", {}),
    },
    };
    export default plugin((api) => {
    const betterStates = api.theme('betterStates', {})

    const values = {
    values: {
    DEFAULT: '',
    ...betterStates
    }
    }

    const selector = (state: string, prefix: MaybeString) => {
    return `${prefix ?? "&"}:${state}:not(:disabled)`;
    };
    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 maybeWrap = (state: string, prefix: string, 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}`);
    };
    const merge = (
    type: UtilType,
    value: MaybeString,
    modifier: string | null,
    callback: (value: MaybeString, prefix: string, merge: boolean) => string[]
    ) => {
    const typeClass = modifier ? `.${type}\\/${api.e(modifier)}` : `.${type}`
    const append = type === 'group' ? ' &' : ' ~ &'
    return callback(value, `:merge(${typeClass})`, true).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),
    ];
    // Hovered ________________________________________________________________________
    const hovered = (value: MaybeString = null, prefix = '&', merge = false) => {
    const postfix = merge ? ' & ' : ''
    return [
    `@media (hover: hover) and (pointer: fine) { ${maybeWrap('hover', prefix, value)}${postfix}}`,
    `@media (hover: hover) and (pointer: fine) { ${maybeWrap('focus-visible', prefix, value)}${postfix}}`,
    `@media (hover: hover) and (pointer: fine) { ${maybeWrap('has(:focus-visible)', prefix, value)}${postfix}}`
    ]
    }

    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
    );
    api.matchVariant('hovered', (value) => hovered(value), values)
    api.matchVariant('group-hovered', (value, { modifier }) => merge('group', value, modifier, hovered), values)
    api.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)} & }`
    ];
    // Pressed ________________________________________________________________________
    const pressed = (value: MaybeString = null, prefix = '&', merge = false) => {
    const postfix = merge ? ' & ' : ''
    return [
    maybeWrap('active', prefix, value),
    maybeWrap('has(:active)', prefix, value),
    // Wrap the entire selector in the media query, not just the state
    `@media (hover: none) or (pointer: coarse) { ${maybeWrap('hover', prefix, value)}${postfix}}`,
    `@media (hover: none) or (pointer: coarse) { ${maybeWrap('focus-visible', prefix, value)}${postfix}}`,
    `@media (hover: none) or (pointer: coarse) { ${maybeWrap('has(:focus-visible)', prefix, value)}${postfix}}`
    ]
    }

    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
    );
    });
    api.matchVariant('pressed', (value) => pressed(value), values)
    api.matchVariant('group-pressed', (value, { modifier }) => merge('group', value, modifier, pressed), values)
    api.matchVariant('peer-pressed', (value, { modifier }) => merge('peer', value, modifier, pressed), values)
    })
    80 changes: 0 additions & 80 deletions tailwind.config.js
    Original file line number Diff line number Diff line change
    @@ -1,80 +0,0 @@
    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,
    )
    }),
    ],
    }
  3. Neophen revised this gist Nov 5, 2024. 2 changed files with 10 additions and 4 deletions.
    7 changes: 5 additions & 2 deletions plugin-better-state.ts
    Original file line number Diff line number Diff line change
    @@ -56,8 +56,11 @@ export default plugin(function ({ matchVariant, theme, e }) {
    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", 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);
    7 changes: 5 additions & 2 deletions tailwind.config.js
    Original file line number Diff line number Diff line change
    @@ -57,8 +57,11 @@ export default {
    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', 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)
  4. Neophen revised this gist Nov 2, 2024. 1 changed file with 34 additions and 0 deletions.
    34 changes: 34 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,34 @@
    # Better hover/active states, Tailwind CSS

    ## Preview:

    https://gist.github.com/user-attachments/assets/4af1f288-6c64-41e0-8765-56fb8e5da29c

    ## Tailwind Play link:

    [https://play.tailwindcss.com/Aj7D2OHw20](https://play.tailwindcss.com/Aj7D2OHw20)

    # Use case: A card with main action and secondary action

    https://gist.github.com/user-attachments/assets/420b6008-965c-424a-a610-6544a1774804

    ## Tailwind Play link:

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

    ## HTML if the link is broken

    ```html
    <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>
    ```
  5. Neophen created this gist Nov 2, 2024.
    42 changes: 42 additions & 0 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,42 @@
    <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>
    74 changes: 74 additions & 0 deletions plugin-better-state.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,74 @@
    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", 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
    );
    });
    77 changes: 77 additions & 0 deletions tailwind.config.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,77 @@
    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', 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,
    )
    }),
    ],
    }
    12 changes: 12 additions & 0 deletions tailwind.config.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,12 @@
    /** @type {import('tailwindcss').Config} */

    export const theme = {
    extend: {
    betterStates: {
    action: ".action",
    },
    };

    export const plugins = [
    require("./plugin-better-state"),
    ];